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 @@ + + + + + + + +
+
+
Hello,
+ <%= typeof jobName !== 'undefined' ? jobName :"____" %> is in <%= typeof + state !== 'undefined' ? state : "____" %> state on <%= typeof + clusterName !== 'undefined' ? clusterName: "____" %>. Please take below + actions +
+ + +
+ + + + + + + + + + + + + + + + + +
Row 1, Column 1Row 1, Column 2Row 1, Column 3Row 1, Column 4Row 1, Column 5
Row 2, Column 1Row 2, Column 2Row 2, Column 3Row 2, Column 4Row 2, Column 5
+
+ + +
+ [MENTIONS] + email1@example.com + email2@example.com + email3@example.com +
+ +
Tombolo
+ +
+ + diff --git a/server/notificationTemplates/teams/sampleOrigin.js b/server/notificationTemplates/teams/sampleOrigin.js new file mode 100644 index 000000000..6f651668d --- /dev/null +++ b/server/notificationTemplates/teams/sampleOrigin.js @@ -0,0 +1,64 @@ +// This is a sample template for a Teams notification card. + +const sampleOrigin = ({teamsData}) => { + return { + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + themeColor: "0076D7", + summary: "Test Summary", + sections: [ + { + activityTitle: "Lorem ipsum dolor sit amet", + activitySubtitle: + "Tconsectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud", + text: "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.", + potentialAction: [ + { + "@type": "ActionCard", + name: "Comment", + inputs: [ + { + "@type": "TextInput", + id: "comment", + isMultiline: true, + title: "Enter your comment", + }, + ], + actions: [ + { + "@type": "HttpPOST", + name: "OK", + target: "http://...", + }, + ], + }, + { + "@type": "ActionCard", + name: "Change Status", + inputs: [ + { + "@type": "MultichoiceInput", + id: "list", + title: "Change the status to", + isMultiSelect: "false", + choices: [ + { display: "In Progress", value: "in_progress" }, + { display: "Done", value: "done" }, + ], + }, + ], + actions: [ + { + "@type": "HttpPOST", + name: "OK", + target: "http://...", + }, + ], + }, + ], + }, + ], + }; +}; + +module.exports = sampleOrigin; diff --git a/server/package-lock.json b/server/package-lock.json index 6e718e1a7..5007186e2 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -16,6 +16,7 @@ "bree": "^8.0.3", "crypto": "^1.0.1", "dotenv": "^8.6.0", + "ejs": "^3.1.9", "express": "^4.17.3", "express-rate-limit": "^5.5.1", "express-validator": "^6.13.0", @@ -2401,7 +2402,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -2417,7 +2417,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "engines": { "node": ">=8" } @@ -2426,7 +2425,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -3001,6 +2999,20 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, + "node_modules/ejs": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.394", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.394.tgz", @@ -3474,6 +3486,33 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -4598,6 +4637,23 @@ "node": ">=8" } }, + "node_modules/jake": { + "version": "10.8.7", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", + "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jest": { "version": "29.5.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.5.0.tgz", @@ -10393,7 +10449,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10402,14 +10457,12 @@ "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" }, "supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "requires": { "has-flag": "^4.0.0" } @@ -10867,6 +10920,14 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=" }, + "ejs": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz", + "integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==", + "requires": { + "jake": "^10.8.5" + } + }, "electron-to-chromium": { "version": "1.4.394", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.394.tgz", @@ -11260,6 +11321,32 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, + "filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "requires": { + "minimatch": "^5.0.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -12051,6 +12138,17 @@ "istanbul-lib-report": "^3.0.0" } }, + "jake": { + "version": "10.8.7", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", + "integrity": "sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==", + "requires": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + } + }, "jest": { "version": "29.5.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.5.0.tgz", diff --git a/server/package.json b/server/package.json index 7760995e8..20123febe 100644 --- a/server/package.json +++ b/server/package.json @@ -20,6 +20,7 @@ "bree": "^8.0.3", "crypto": "^1.0.1", "dotenv": "^8.6.0", + "ejs": "^3.1.9", "express": "^4.17.3", "express-rate-limit": "^5.5.1", "express-validator": "^6.13.0", diff --git a/server/routes/notification_queue/read.js b/server/routes/notification_queue/read.js new file mode 100644 index 000000000..18fd51a81 --- /dev/null +++ b/server/routes/notification_queue/read.js @@ -0,0 +1,139 @@ +const express = require("express"); +const router = express.Router(); +const { body, check } = require("express-validator"); + +//Local imports +const logger = require("../../config/logger"); +const models = require("../../models"); +const { validationResult } = require("express-validator"); + +//Constants +const NotificationQueue = models.notification_queue; + +// Create new notification +router.post( + "/", + [ + // body("type").notEmpty().withMessage("Notification medium Type is required"), + body("deliveryType") + .notEmpty() + .withMessage("Send schedule is required"), + body("cron") + .optional() + .isString() + .withMessage("Cron must be a string if provided"), + body("lastScanned") + .optional() + .isDate() + .withMessage("Last scanned must be a date if provided"), + body("attemptCount") + .optional() + .isInt() + .withMessage("Attempt count must be an integer if provided"), + body("failureMessage") + .optional() + .isString() + .withMessage("Failure message must be a string if provided"), + body("createdBy").notEmpty().withMessage("Created by is required"), + body("metaData") + .notEmpty() + .isObject() + .withMessage("Meta data must be an object if provided"), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const response = await NotificationQueue.create(req.body, { raw: true }); + res.status(200).send(response); + } catch (err) { + logger.error(err); + res.status(500).send("Failed to save notification"); + } + } +); + +// Get all notifications +router.get("/", async (req, res) => { + try { + const notifications = await NotificationQueue.findAll(); + res.status(200).json(notifications); + } catch (err) { + logger.error(err); + res.status(500).send("Failed to get notifications"); + } +}); + +// Patch a single notification +router.patch( + "/", + [ + body("id").isUUID().withMessage("ID must be a valid UUID"), + body("type").notEmpty().withMessage("Type is required"), + body("sendSchedule").notEmpty().withMessage("Send schedule is required"), + body("cron") + .optional() + .isString() + .withMessage("Cron must be a string if provided"), + body("lastScanned") + .optional() + .isDate() + .withMessage("Last scanned must be a date if provided"), + body("attemptCount") + .optional() + .isInt() + .withMessage("Attempt count must be an integer if provided"), + body("failureMessage") + .optional() + .isString() + .withMessage("Failure message must be a string if provided"), + body("createdBy").notEmpty().withMessage("Created by is required"), + body("metaData") + .optional() + .isObject() + .withMessage("Meta data must be an object if provided"), + ], + async (req, res) => { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ errors: errors.array() }); + } + + const updatedRows = await NotificationQueue.update(req.body, { + where: { id: req.body.id }, + returning: true, + }); + + if (updatedRows[0] === 0) { + return res.status(404).send("Notification not found"); + } + + const updatedNotification = await NotificationQueue.findByPk(req.body.id); + res.status(200).send(updatedNotification); + } catch (err) { + logger.error(err); + res.status(500).send("Failed to update notification"); + } + } +); + +// Delete a single notification +router.delete( + "/:id", + [check("id", "Invalid id").isUUID()], + async (req, res) => { + try { + await NotificationQueue.destroy({ where: { id: req.params.id } }); + res.status(200).send("success"); + } catch (err) { + logger.error(err); + res.status(500).send("Failed to delete notification"); + } + } +); + +module.exports = router; diff --git a/server/server.js b/server/server.js index ab34e9f7e..06dc98444 100644 --- a/server/server.js +++ b/server/server.js @@ -29,9 +29,11 @@ const socketIo = require("socket.io")(server); module.exports.io = socketIo; app.set("trust proxy", 1); + +// Limit rate of requests to 400 per 15 minutes const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 400, // limit each IP to 400 requests per windowMs + windowMs: 15 * 60 * 1000, + max: 400, }); // MIDDLEWARE -> apply to all requests @@ -78,6 +80,13 @@ const cluster = require("./routes/cluster/read"); const orbit = require("./routes/orbit/read"); const integrations = require("./routes/integrations/read"); const teamsHook = require("./routes/msTeamsHook/read"); +const notification_queue = require("./routes/notification_queue/read"); + +// Log all HTTP requests +app.use((req, res, next) => { + logger.http(`[${req.ip}] [${req.method}] [${req.url}]`); + next(); +}); app.use("/api/user", userRead); app.use("/api/updateNotification", updateNotifications); @@ -116,13 +125,16 @@ app.use("/api/cluster", cluster); app.use("/api/orbit", orbit); app.use("/api/integrations", integrations); app.use("/api/teamsHook", teamsHook); +app.use("/api/notification_queue", notification_queue); +// Safety net for unhandled errors app.use((err, req, res, next) => { logger.error("Error caught by Express error handler", err); res.status(500).send("Something went wrong"); }); -// process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = 0; +// Disables SSL verification for self-signed certificates in development mode +process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = process.env.NODE_ENV === "production" ? 1 : 0; /* Start server */ server.listen(port, "0.0.0.0", async () => {