diff --git a/server/config/emailConfig.js b/server/config/emailConfig.js new file mode 100644 index 000000000..41bd2f895 --- /dev/null +++ b/server/config/emailConfig.js @@ -0,0 +1,61 @@ +/* +1. The send Email function treats emails as sent if the SMTP server accepts the email for delivery. +2. It does not guarantee that the email will be delivered to the recipient's inbox. + For example - if the user provided email is correctly formatted but not a valid email address, + the SMTP server will accept the email for delivery but will not deliver it to the recipient's inbox. +3. To Debug email delivery issues, first check notification_queue table. if it does not exists there check table that stores sent notification. + Next application Logs and SMTP server logs if available. +*/ + +//Packages imports +const nodemailer = require("nodemailer"); + +//Local imports +const logger = require("./logger"); + +// SMTP configuration +const smtpConfig = { + host: process.env.EMAIL_SMTP_HOST, + port: process.env.EMAIL_PORT, + secure: false, // use SSL, + tls: { rejectUnauthorized: false }, + sender: process.env.EMAIL_SENDER, + timeout: 10000, // in milliseconds + debug: true, // set debug to true to see debug logs +}; + + +//Create transporter +const transporter = nodemailer.createTransport(smtpConfig); + +// Send email function +const sendEmail = ({receiver, cc, subject, plainTextBody, htmlBody}) => { + return new Promise((resolve, reject) => { + const mailOptions = { + from: smtpConfig.sender, + to: receiver, + cc: cc, + subject: subject, + text: plainTextBody ? plainTextBody : null, + html: htmlBody ? htmlBody : null, + }; + transporter.sendMail(mailOptions, function (error, info) { + if (error) { + reject(error); + } + resolve(info); + }); + }); +}; + +// Re-try options +const retryOptions = { + maxRetries: 3, + retryDelays: [1, 2, 3], // in minutes - Exponential backoff strategy +}; + +// Exports +module.exports = { + sendEmail, + retryOptions, +}; \ No newline at end of file diff --git a/server/config/logger.js b/server/config/logger.js index 29cbabcae..e8e81f5f1 100644 --- a/server/config/logger.js +++ b/server/config/logger.js @@ -2,6 +2,14 @@ const { createLogger, format, transports } = require('winston'); // docs: https://github.com/winstonjs/winston const isProduction = process.env.NODE_ENV === 'production'; +// Print logs in color depending on log type +format.colorize().addColors({ + error: "red", + warn: "yellow", + info: "green", + http: "magenta", +}); + const getFormat = () => format.combine( isProduction ? format.uncolorize() : format.colorize({ all: true }), // adding or removing colors depending on logs type; @@ -27,7 +35,10 @@ let DEFAULT_LOG_LEVEL = 'http'; // Initialize logger const logger = createLogger({ exitOnError: false, - format: format.combine(format.errors({ stack: true }), format.timestamp()), // this will be common setting for all transports; + format: format.combine( + format.errors({ stack: true }), + format.timestamp() + ), // this will be common setting for all transports; transports: [ new transports.Console({ ...common, diff --git a/server/job-scheduler.js b/server/job-scheduler.js index d6c8e905f..cdaaf3082 100644 --- a/server/job-scheduler.js +++ b/server/job-scheduler.js @@ -1,7 +1,6 @@ const Bree = require("bree"); const logger = require("./config/logger"); - const { logBreeJobs, createNewBreeJob, @@ -13,7 +12,6 @@ const { startJob, startAllJobs, } = require("./jobSchedularMethods/breeJobs.js"); - const { scheduleCheckForJobsWithSingleDependency, executeJob, @@ -27,13 +25,11 @@ const { createClusterMonitoringBreeJob, scheduleClusterMonitoringOnServerStart, } = require("./jobSchedularMethods/clusterJobs.js"); - const { createJobMonitoringBreeJob, scheduleJobMonitoringOnServerStart, scheduleJobStatusPolling, } = require("./jobSchedularMethods/hpccJobs.js"); - const { createLandingZoneFileMonitoringBreeJob, createLogicalFileMonitoringBreeJob, @@ -43,6 +39,8 @@ const { scheduleFileMonitoringOnServerStart, scheduleFileMonitoring, } = require("./jobSchedularMethods/hpccFiles.js"); +const { scheduleKeyCheck } = require("./jobSchedularMethods/apiKeys.js"); +const {scheduleEmailNotificationProcessing, scheduleTeamsNotificationProcessing} = require("./jobSchedularMethods/notificationJobs.js"); const { createOrbitMegaphoneJob, @@ -121,8 +119,10 @@ class JobScheduler { await this.scheduleSuperFileMonitoringOnServerStart(); await this.scheduleClusterMonitoringOnServerStart(); await this.scheduleKeyCheck(); - await this.scheduleJobMonitoringOnServerStart(); + // await this.scheduleJobMonitoringOnServerStart(); await this.createClusterUsageHistoryJob(); + await this.scheduleEmailNotificationProcessing(); + await this.scheduleTeamsNotificationProcessing(); await this.scheduleOrbitMonitoringOnServerStart(); await this.createOrbitMegaphoneJob(); @@ -325,6 +325,13 @@ class JobScheduler { return scheduleKeyCheck.call(this); } + //Process notification queue + scheduleEmailNotificationProcessing() { + return scheduleEmailNotificationProcessing.call(this); + } + scheduleTeamsNotificationProcessing(){ + return scheduleTeamsNotificationProcessing.call(this); + } //orbit jobs createOrbitMegaphoneJob() { return createOrbitMegaphoneJob.call(this); diff --git a/server/jobSchedularMethods/notificationJobs.js b/server/jobSchedularMethods/notificationJobs.js new file mode 100644 index 000000000..88df044ea --- /dev/null +++ b/server/jobSchedularMethods/notificationJobs.js @@ -0,0 +1,56 @@ + +const path = require("path"); +const logger = require("../config/logger"); +const PROCESS_EMAIL_NOTIFICATIONS = path.join("notifications", "processEmailNotifications.js"); +const PROCESS_TEAMS_NOTIFICATIONS = path.join("notifications", "processTeamsNotifications.js"); + +async function scheduleEmailNotificationProcessing() { + try { + let jobName = "email-notification-processing-" + new Date().getTime(); + this.bree.add({ + name: jobName, + interval: "10s", // Make it 120 seconds in production + path: path.join(__dirname, "..", "jobs", PROCESS_EMAIL_NOTIFICATIONS), + worker: { + workerData: { + jobName: jobName, + WORKER_CREATED_AT: Date.now(), + }, + }, + }); + + this.bree.start(jobName); + logger.info("🔔 E-MAIL NOTIFICATION PROCESSING STARTED ..."); + } catch (err) { + console.error(err); + } +} + +async function scheduleTeamsNotificationProcessing() { + try { + let jobName = "teams-notification-processing-" + new Date().getTime(); + this.bree.add({ + name: jobName, + interval: "10s", // Make it 120 seconds in production + path: path.join(__dirname, "..", "jobs", PROCESS_TEAMS_NOTIFICATIONS), + worker: { + workerData: { + jobName: jobName, + WORKER_CREATED_AT: Date.now(), + }, + }, + }); + + this.bree.start(jobName); + logger.info("🔔 TEAMS NOTIFICATION PROCESSING STARTED ..."); + } catch (err) { + console.error(err); + } +} + + + +module.exports = { + scheduleEmailNotificationProcessing, + scheduleTeamsNotificationProcessing, +}; \ No newline at end of file diff --git a/server/jobs/notifications/notificationsHelperFunctions.js b/server/jobs/notifications/notificationsHelperFunctions.js new file mode 100644 index 000000000..f341345aa --- /dev/null +++ b/server/jobs/notifications/notificationsHelperFunctions.js @@ -0,0 +1,61 @@ +const path = require("path"); +const ejs = require("ejs"); +const fs = require("fs"); +const models = require("../../models"); +const logger = require("../../config/logger"); + +const NotificationQueue = models.notification_queue; +const { retryOptions: { maxRetries, retryDelays } } = require("../../config/emailConfig"); + +// Renders HTML template for email notification +const renderEmailBody = ({ notificationOrigin, emailData }) => { + const templatePath = path.join( __dirname, "..", "..", "notificationTemplates","email", `${notificationOrigin}.ejs`); + const template = fs.readFileSync(templatePath, "utf-8") + return ejs.render(template, emailData); +}; + +// Function to calculate the retryAfter time +const calculateRetryAfter = ({ + attemptCount, + retryDelays, // Configs related to emails should not be passed as params + maxRetries, + currentDateTime, +}) => { + if (attemptCount === maxRetries - 1) { + return null; + } else { + return new Date(currentDateTime + retryDelays[attemptCount] * 60000); + } +}; + +//Update notification queue on error +async function updateNotificationQueueOnError({ notificationId, + attemptCount, + notification, + error + }) { + try { + await NotificationQueue.update( + { + attemptCount: attemptCount + 1, + failureMessage: { err: error.message, notification }, + reTryAfter: calculateRetryAfter({ + attemptCount, + retryDelays: retryDelays, + maxRetries: maxRetries, + currentDateTime: Date.now(), + }), + }, + { where: { id: notificationId } } + ); + } catch (updateError) { + logger.error(updateError); + } +} + + +module.exports = { + renderEmailBody, + calculateRetryAfter, + updateNotificationQueueOnError, +}; \ No newline at end of file diff --git a/server/jobs/notifications/processEmailNotifications.js b/server/jobs/notifications/processEmailNotifications.js new file mode 100644 index 000000000..37c5d652a --- /dev/null +++ b/server/jobs/notifications/processEmailNotifications.js @@ -0,0 +1,160 @@ +// Packages +const {Op} = require("sequelize"); + +//Local Imports +const models = require("../../models"); +const logger = require("../../config/logger"); +const { sendEmail, retryOptions:{ maxRetries, retryDelays} } = require("../../config/emailConfig"); +const { + renderEmailBody, + updateNotificationQueueOnError, +} = require("./notificationsHelperFunctions"); + +const NotificationQueue = models.notification_queue; +const Notification = models.monitoring_notifications; + +(async () => { + try{ + const now = Date.now(); + let notifications; + const notificationsToBeSent = []; // Notification that meets the criteria to be sent + const successfulDelivery = [] + + try { + // Get notifications + notifications = await NotificationQueue.findAll({ + where: { + type: "email", + attemptCount: { [Op.lt]: maxRetries }, + }, + raw: true, + }); + } catch (err) { + logger.error(err); + return; + } + + for (let notification of notifications) { + const { + id, + notificationOrigin, + deliveryType, + attemptCount, + metaData, + reTryAfter, + lastScanned, + deliveryTime, + } = notification; + const emailDetails = metaData?.emailDetails; + + // Check if it meets the criteria to be sent + if ( + (deliveryType === "immediate" && (reTryAfter < now || !reTryAfter)) || + (deliveryType === "scheduled" && (reTryAfter < now || !reTryAfter)) || + (deliveryType === "scheduled" && + deliveryTime < now && + deliveryTime > lastScanned) + ) { + try { + //Common email details + const commonEmailDetails = { + receiver: emailDetails?.mainRecipients.join(",") || "", + cc: emailDetails?.cc.join(",") || "", + subject: emailDetails.subject, + notificationId: id, + attemptCount, + }; + + // Notification origin is manual - send the email as it is + if (notificationOrigin === "manual") { + notificationsToBeSent.push({ + ...commonEmailDetails, + plainTextBody: emailDetails.body, + }); + } else { + // If notification origin is not manual, match the template + notificationsToBeSent.push({ + ...commonEmailDetails, + htmlBody: renderEmailBody({ + notificationOrigin, + emailData: emailDetails.data, + }), + }); + } + } catch (error) { + await updateNotificationQueueOnError({ + notificationId: notification.id, + attemptCount, + notification, + error, + }); + } + } + } + + + // If there are notifications to be sent + for (let notification of notificationsToBeSent) { + const { + receiver, + cc, + subject, + plainTextBody, + htmlBody, + notificationId, + attemptCount, + } = notification; + + try { + await sendEmail({ receiver, cc, subject, plainTextBody, htmlBody }); + successfulDelivery.push(notificationId); + + // If email is sent successfully , delete the notification from the queue + await NotificationQueue.destroy({ + where: { id: notificationId }, + }); + + } catch (error) { + // If email failed to send, update the notification queue + logger.error(error); + + // Update notification queue + await updateNotificationQueueOnError({ + notificationId, + attemptCount, + notification, + error, + }); + } + } + + // Update last scanned + try { + await NotificationQueue.update({ lastScanned: now }, { where: {} }); + } catch (error) { + logger.error(error); + } + + //Update notifications table + //TODO - Notifications table should be refactored to accommodate ASR needs + try { + await Notification.bulkCreate( + successfulDelivery.map((id) => ({ notificationQueueId: id })) + ); + } catch (error) { + logger.error(error); + } + } + catch (error) { + logger.error(error); + } +})(); + +/* NOTES +1. new Date() - gives local time +2. new Date().toISOString() - gives UTC time in ISO 8601 format +3. Sequelize by default stores the date in UTC format +4. Sequelize by default returns the date in local time +5. Gotcha - If you console.log new Date() in node.js environment, It will log UTC time in ISO 8601 format. + It is because node.js internally calls .toISOString() on the date object before logging it. +*/ \ No newline at end of file diff --git a/server/jobs/notifications/processTeamsNotifications.js b/server/jobs/notifications/processTeamsNotifications.js new file mode 100644 index 000000000..5b95a591f --- /dev/null +++ b/server/jobs/notifications/processTeamsNotifications.js @@ -0,0 +1,170 @@ +// Modules +const { Op } = require("sequelize"); +const axios = require("axios"); +const path = require("path"); + +//Local Imports +const models = require("../../models"); +const logger = require("../../config/logger"); +const {retryOptions: { maxRetries, retryDelays }} = require("../../config/emailConfig"); +const {updateNotificationQueueOnError} = require("./notificationsHelperFunctions"); +const NotificationQueue = models.notification_queue; +const TeamsHook = models.teams_hook; +const Notification = models.monitoring_notifications; + +(async () => { + const notificationsToBeSent = []; // That meets the criteria to be sent + const now = Date.now(); + const successfulDelivery=[] + + try { + let notifications; + + try { + notifications = await NotificationQueue.findAll({ + where: { + type: "msTeams", + attemptCount: { [Op.lt]: maxRetries }, + }, + }); + } catch (err) { + logger.error(err); + return; + } + + // Loop through all notifications and check if it meets the criteria to be sent + for (let notification of notifications) { + const { + id, + notificationOrigin, + deliveryType, + attemptCount, + metaData, + reTryAfter, + lastScanned, + deliveryTime, + } = notification; + const msTeamsDetails = metaData?.msTeamsDetails; + + if ( + (deliveryType === "immediate" && (reTryAfter < now || !reTryAfter)) || + (deliveryType === "scheduled" && (reTryAfter < now || !reTryAfter)) || + (deliveryType === "scheduled" && + deliveryTime < now && + deliveryTime > lastScanned) + ) { + try { + //Common teams details + const commonMsTeamsDetails = { + receiver: msTeamsDetails?.recipients, + notificationId: id, + attemptCount, + }; + + // If notification origin is manual, send the email as it is + if (notificationOrigin === "manual") { + notificationsToBeSent.push({ + ...commonMsTeamsDetails, + messageCard: `**${msTeamsDetails.subject}**\n\n${msTeamsDetails.htmlBody}`, + }); + } else { + //Import correct card file + const getTemplate = require(path.join( + "..", + "..", + "notificationTemplates", + "teams", + `${notificationOrigin}.js` + )); + + //Get message card + const messageCard = getTemplate({ + notificationData: msTeamsDetails.data, + }); + + notificationsToBeSent.push({ + ...commonMsTeamsDetails, + messageCard, + }); + } + } catch (error) { + logger.error(error); + //If error occurs - increment attempt count, update reTryAfter + await updateNotificationQueueOnError({ + notificationId: notification.id, + attemptCount, + notification, + error, + }); + } + } + } + + // If there are notifications to be sent + for (let notification of notificationsToBeSent) { + const { receiver, messageCard, notificationId, attemptCount } = + notification; + try { + // Receiver is array of hook IDs - get URL from database + const hooksObj = await TeamsHook.findAll({ + where: { id: receiver }, + attributes: ["url"], + raw: true, + }); + + // Teams end points + const hooks = hooksObj.map((h) => h.url); + const requests = hooks.map((h) => axios.post(h, messageCard)); + + const response = await Promise.allSettled(requests); + + for (res of response) { + // If delivered - destroy the notification + if (res.status === "fulfilled") { + // Destroy the notification from queue + try { + await NotificationQueue.destroy({ + where: { id: notificationId }, + }); + } catch (err) { + logger.error(err); + } + successfulDelivery.push(notificationId); + } else { + logger.error({ err: res.reason }); + //Update the notification queue if failed to send + await updateNotificationQueueOnError({ + attemptCount, + notificationId, + notification, + error: { message: res.reason }, + }); + } + } + } catch (err) { + logger.error(err); + } + } + + // Update last scanned + try { + await NotificationQueue.update({ lastScanned: now }, { where: {} }); + } catch (error) { + logger.error(error); + } + + //Update notifications table + //TODO - Notifications table should be refactored to accommodate ASR needs + try { + await Notification.bulkCreate( + successfulDelivery.map((id) => ({ notificationQueueId: id })) + ); + } catch (error) { + logger.error(error); + } + + + } catch (error) { + logger.error(error); + } +})(); diff --git a/server/migrations/20240119162245-create-notification-queue-table.js b/server/migrations/20240119162245-create-notification-queue-table.js new file mode 100644 index 000000000..0d3eb5748 --- /dev/null +++ b/server/migrations/20240119162245-create-notification-queue-table.js @@ -0,0 +1,77 @@ +"use strict"; +module.exports = { + up: (queryInterface, Sequelize) => { + return queryInterface.createTable("notification_queue", { + id: { + allowNull: false, + primaryKey: true, + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + }, + type: { + allowNull: false, + type: Sequelize.ENUM("msTeams", "email"), + }, + notificationOrigin: { + allowNull: false, + type: Sequelize.STRING, + }, + originationId: { + allowNull: true, + type: Sequelize.UUID, + }, + deliveryType: { + allowNull: false, + type: Sequelize.ENUM("immediate", "scheduled"), + }, + deliveryTime: { + allowNull: true, + type: Sequelize.DATE + }, + lastScanned: { + allowNull: true, + type: Sequelize.DATE, + }, + attemptCount: { + allowNull: false, + type: Sequelize.DataTypes.INTEGER, + defaultValue: 0, + }, + failureMessage: { + allowNull: true, + type: Sequelize.DataTypes.JSON, + }, + reTryAfter: { + allowNull: true, + type: Sequelize.DATE, + }, + createdBy: { + allowNull: false, + type: Sequelize.DataTypes.STRING, + defaultValue: "System", + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.NOW, + }, + updatedBy: { + allowNull: false, + type: Sequelize.STRING, + defaultValue: "System", + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.NOW, + }, + metaData: { + allowNull: true, + type: Sequelize.JSON, + }, + }); + }, + down: (queryInterface, Sequelize) => { + return queryInterface.dropTable("notification_queue"); + }, +}; diff --git a/server/models/notification_queue.js b/server/models/notification_queue.js new file mode 100644 index 000000000..997c49fc5 --- /dev/null +++ b/server/models/notification_queue.js @@ -0,0 +1,146 @@ +"use strict"; +module.exports = (sequelize, DataTypes) => { + const NotificationQueue = sequelize.define( + "notification_queue", + { + id: { + allowNull: false, + primaryKey: true, + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + }, + type: { + allowNull: false, + type: DataTypes.ENUM("msTeams", "email"), + }, + notificationOrigin: { + allowNull: false, + type: DataTypes.STRING, + }, + /* + This is the id of the monitoring that triggered the notification + allowNull: true because this field will be null for manual notifications and other notifications that are not triggered by a monitoring + This is required so that we can filter notifications by monitoring + */ + originationId: { + allowNull: true, + type: DataTypes.UUID, + }, + deliveryType: { + allowNull: false, + type: DataTypes.ENUM("immediate", "scheduled"), + }, + deliveryTime: { + allowNull: true, + type: DataTypes.DATE, + }, + lastScanned: { + allowNull: true, + type: DataTypes.DATE, + }, + attemptCount: { + allowNull: false, + type: DataTypes.INTEGER, + defaultValue: 0, + }, + reTryAfter: { + allowNull: true, + type: DataTypes.DATE, + }, + failureMessage: { + allowNull: true, + type: DataTypes.JSON, + }, + createdBy: { + allowNull: false, + type: DataTypes.STRING, + defaultValue: "System", + }, + createdAt: { + allowNull: false, + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updatedBy: { + allowNull: false, + type: DataTypes.STRING, + defaultValue: "System", + }, + updatedAt: { + allowNull: false, + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + metaData: { + allowNull: true, + type: DataTypes.JSON, + }, + }, + { + freezeTableName: true, + } + ); + + return NotificationQueue; +}; + +// SAMPLE PAYLOADS +// E-mail notification +/* +{ +// "id": uuid +"type": "email", +"notificationOrigin": "sampleOrigin", +"deliveryType": "immediate", +"deliveryTime": null, +// "lastScanned": null, +// "attemptCount": +// "failureMessage" : +// "retryAfter": null, +"createdBy": "{name: 'John Doe', email: john.doe@testemail.com, id: doe01", +"updatedBy": "{name: 'John Doe', email: john.doe@testemail.com, id: doe01", +"metaData": { + "emailDetails" : { + "mainRecipients" : [ "john.doe@testemail.com", "test-teams-email@.onmicrosoft.comamer.teams.ms"], + "cc": ["jane.doe@testemail.com"], + "subject" : "This is a manual notification for test", + "body" : "If notification is manual, you cannot send data that will be passed to a template . You need to pass plain text message", + "data" : { + "jobName" : "Test Job", + "clusterName" : "4 Way Cluster", + "state" :"Failed", + "actions": ["Take corrective action ASAP", "Re run job", "Abort dependent jobs"] + }} + } +} +*/ + +// MS Teams notification +/* +{ +// "id": uuid +"type": "msTeams", +"notificationOrigin": "sampleOrigin", +"deliveryType": "immediate", +"deliveryTime": null, +// "lastScanned": null, +// "attemptCount": +// "failureMessage" : +// "retryAfter": null, +"createdBy": "{name: 'John Doe', email: john.doe@testemail.com, id: doe01", +"updatedBy": "{name: 'John Doe', email: john.doe@testemail.com, id: doe01", +"metaData": { + "msTeamsDetails" : { + "recipients" : ["df325211-0b49-4a6a-8f49-124fd8879ab8", "9c421c55-31a7-4ec7-95b2-bf27c10e6256" ], + "subject" : "This is a manual notification for test", + "body" : "If notification is manual, you cannot send data that will be passed to a template . You need to pass plain text message", + "data":{ + "jobName" : "Test Job", + "clusterName" : "Name of cluster", + "state" :"Failed", + "actions": ["Take corrective action ASAP", "Re run job", "Abort dependent jobs"] + } + } + } +} +*/ \ No newline at end of file diff --git a/server/models/teams_hook.js b/server/models/teams_hook.js index 6b25f7b23..c2f601b97 100644 --- a/server/models/teams_hook.js +++ b/server/models/teams_hook.js @@ -51,6 +51,9 @@ const teams_hook = sequelize.define("teams_hook", { allowNull: true, type: DataTypes.DATE, }, +}, +{ + paranoid: true, }); return teams_hook; diff --git a/server/notificationTemplates/email/sampleOrigin.ejs b/server/notificationTemplates/email/sampleOrigin.ejs new file mode 100644 index 000000000..abb731194 --- /dev/null +++ b/server/notificationTemplates/email/sampleOrigin.ejs @@ -0,0 +1,86 @@ + + + +
+ + + +Row 1, Column 1 | +Row 1, Column 2 | +Row 1, Column 3 | +Row 1, Column 4 | +Row 1, Column 5 | +
Row 2, Column 1 | +Row 2, Column 2 | +Row 2, Column 3 | +Row 2, Column 4 | +Row 2, Column 5 | +