From d59e2796e7d269a54be8de14232ab66a4d024bf2 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 24 Jul 2025 13:50:21 -0700 Subject: [PATCH] refactor to monitor service --- server/controllers/monitorController.js | 444 ++++-------------- server/db/models/Monitor.js | 12 +- server/db/mongo/modules/monitorModule.js | 13 +- .../db/mongo/modules/monitorModuleQueries.js | 1 + server/index.js | 79 ++-- server/routes/monitorRoute.js | 5 - server/service/business/monitorService.js | 265 +++++++++++ server/utils/errorUtils.js | 6 +- 8 files changed, 421 insertions(+), 404 deletions(-) create mode 100644 server/service/business/monitorService.js diff --git a/server/controllers/monitorController.js b/server/controllers/monitorController.js index 9943a765a..0f0dd3e76 100755 --- a/server/controllers/monitorController.js +++ b/server/controllers/monitorController.js @@ -4,8 +4,6 @@ import { getMonitorsByTeamIdParamValidation, getMonitorsByTeamIdQueryValidation, createMonitorBodyValidation, - createMonitorsBodyValidation, - getMonitorURLByQueryValidation, editMonitorBodyValidation, pauseMonitorParamValidation, getMonitorStatsByIdParamValidation, @@ -15,21 +13,18 @@ import { getHardwareDetailsByIdQueryValidation, } from "../validation/joi.js"; import sslChecker from "ssl-checker"; -import logger from "../utils/logger.js"; -import axios from "axios"; -import seedDb from "../db/mongo/utils/seedDb.js"; -import pkg from "papaparse"; -import { asyncHandler, createServerError } from "../utils/errorUtils.js"; +import { asyncHandler } from "../utils/errorUtils.js"; import { fetchMonitorCertificate } from "./controllerUtils.js"; const SERVICE_NAME = "monitorController"; class MonitorController { - constructor(db, settingsService, jobQueue, stringService, emailService) { + constructor({ db, settingsService, jobQueue, stringService, emailService, monitorService }) { this.db = db; this.settingsService = settingsService; this.jobQueue = jobQueue; this.stringService = stringService; this.emailService = emailService; + this.monitorService = monitorService; } async verifyTeamAccess(teamId, monitorId) { @@ -41,18 +36,9 @@ class MonitorController { } } - /** - * Returns all monitors - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @param {function} next - * @returns {Promise} - * @throws {Error} - */ getAllMonitors = asyncHandler( - async (req, res, next) => { - const monitors = await this.db.getAllMonitors(); + async (req, res) => { + const monitors = await this.monitorService.getAllMonitors(); return res.success({ msg: this.stringService.monitorGetAll, data: monitors, @@ -63,56 +49,47 @@ class MonitorController { ); getUptimeDetailsById = asyncHandler( - async (req, res, next) => { - const { monitorId } = req.params; - const { dateRange, normalize } = req.query; + async (req, res) => { + const monitorId = req?.params?.monitorId; + const dateRange = req?.query?.dateRange; + const normalize = req?.query?.normalize; const teamId = req?.user?.teamId; + if (!teamId) { throw new Error("Team ID is required"); } - await this.verifyTeamAccess(teamId, monitorId); - - const data = await this.db.getUptimeDetailsById({ + const data = await this.monitorService.getUptimeDetailsById({ + teamId, monitorId, dateRange, normalize, }); return res.success({ msg: this.stringService.monitorGetByIdSuccess, - data, + data: data, }); }, SERVICE_NAME, "getUptimeDetailsById" ); - /** - * Returns monitor stats for monitor with matching ID - * @async - * @param {Express.Request} req - * @param {Express.Response} res - * @param {function} next - * @returns {Promise} - * @throws {Error} - */ getMonitorStatsById = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getMonitorStatsByIdParamValidation.validateAsync(req.params); await getMonitorStatsByIdQueryValidation.validateAsync(req.query); let { limit, sortOrder, dateRange, numToDisplay, normalize } = req.query; - const { monitorId } = req.params; + const monitorId = req?.params?.monitorId; const teamId = req?.user?.teamId; if (!teamId) { throw new Error("Team ID is required"); } - await this.verifyTeamAccess(teamId, monitorId); - - const monitorStats = await this.db.getMonitorStatsById({ + const monitorStats = await this.monitorService.getMonitorStatsById({ + teamId, monitorId, limit, sortOrder, @@ -120,6 +97,7 @@ class MonitorController { numToDisplay, normalize, }); + return res.success({ msg: this.stringService.monitorStatsById, data: monitorStats, @@ -139,7 +117,7 @@ class MonitorController { * @throws {Error} - Throws error if monitor not found or other database errors */ getHardwareDetailsById = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getHardwareDetailsByIdParamValidation.validateAsync(req.params); await getHardwareDetailsByIdQueryValidation.validateAsync(req.query); @@ -150,8 +128,12 @@ class MonitorController { throw new Error("Team ID is required"); } - await this.verifyTeamAccess(teamId, monitorId); - const monitor = await this.db.getHardwareDetailsById({ monitorId, dateRange }); + const monitor = await this.monitorService.getHardwareDetailsById({ + teamId, + monitorId, + dateRange, + }); + return res.success({ msg: this.stringService.monitorGetByIdSuccess, data: monitor, @@ -180,34 +162,17 @@ class MonitorController { "getMonitorCertificate" ); - /** - * Retrieves a monitor by its ID. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.params - The parameters of the request. - * @property {string} req.params.monitorId - The ID of the monitor to be retrieved. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message, and the retrieved monitor data. - * @throws {Error} If there is an error during the process, especially if the monitor is not found (404) or if there is a validation error (422). - */ getMonitorById = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getMonitorByIdParamValidation.validateAsync(req.params); await getMonitorByIdQueryValidation.validateAsync(req.query); - const monitor = await this.db.getMonitorById(req.params.monitorId); - const teamId = req?.user?.teamId; if (!teamId) { throw new Error("Team ID is required"); } - if (!monitor.teamId.equals(teamId)) { - const error = new Error("Unauthorized"); - error.status = 403; - throw error; - } + const monitor = await this.monitorService.getMonitorById({ teamId, monitorId: req?.params?.monitorId }); return res.success({ msg: this.stringService.monitorGetByIdSuccess, @@ -218,30 +183,15 @@ class MonitorController { "getMonitorById" ); - /** - * Creates a new monitor and adds it to the job queue. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {Array} req.body.notifications - The notifications associated with the monitor. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the creation of the monitor, and the created monitor data. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). - */ createMonitor = asyncHandler( - async (req, res, next) => { + async (req, res) => { await createMonitorBodyValidation.validateAsync(req.body); - const { _id, teamId } = req.user; - const monitor = await this.db.createMonitor({ - body: req.body, - teamId, - userId: _id, - }); + const userId = req?.user?._id; + const teamId = req?.user?.teamId; + + const monitor = await this.monitorService.createMonitor({ teamId, userId, body: req.body }); - // Add monitor to job queue - this.jobQueue.addJob(monitor._id, monitor); return res.success({ msg: this.stringService.monitorCreate, data: monitor, @@ -251,141 +201,45 @@ class MonitorController { "createMonitor" ); - /** - * Creates bulk monitors and adds them to the job queue after parsing CSV. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.file - The uploaded CSV file. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status and message. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). - */ createBulkMonitors = asyncHandler( - async (req, res, next) => { - const { parse } = pkg; - - // validate the file + async (req, res) => { if (!req.file) { throw new Error("No file uploaded"); } - // Check if the file is a CSV if (!req.file.mimetype.includes("csv")) { throw new Error("File is not a CSV"); } - // Validate if the file is empty if (req.file.size === 0) { throw new Error("File is empty"); } - const { _id, teamId } = req.user; + const userId = req?.user?._id; + const teamId = req?.user?.teamId; - if (!_id || !teamId) { + if (!userId || !teamId) { throw new Error("Missing userId or teamId"); } - // Get file buffer from memory and convert to string - const fileData = req.file.buffer.toString("utf-8"); + const fileData = req?.file?.buffer?.toString("utf-8"); + if (!fileData) { + throw new Error("Cannot get file from buffer"); + } - // Parse the CSV data - parse(fileData, { - header: true, - skipEmptyLines: true, - transform: (value, header) => { - if (value === "") return undefined; // Empty fields become undefined + const monitors = await this.monitorService.createBulkMonitors({ fileData, userId, teamId }); - // Handle 'port' and 'interval' fields, check if they're valid numbers - if (["port", "interval"].includes(header)) { - const num = parseInt(value, 10); - if (isNaN(num)) { - throw new Error(`${header} should be a valid number, got: ${value}`); - } - return num; - } - - return value; - }, - complete: async ({ data, errors }) => { - if (errors.length > 0) { - throw createServerError("Error parsing CSV"); - } - - if (!data || data.length === 0) { - throw createServerError("CSV file contains no data rows"); - } - - const enrichedData = data.map((monitor) => ({ - userId: _id, - teamId, - ...monitor, - description: monitor.description || monitor.name || monitor.url, - name: monitor.name || monitor.url, - type: monitor.type || "http", - })); - - await createMonitorsBodyValidation.validateAsync(enrichedData); - - const monitors = await this.db.createBulkMonitors(enrichedData); - - await Promise.all( - monitors.map(async (monitor, index) => { - this.jobQueue.addJob(monitor._id, monitor); - }) - ); - - return res.success({ - msg: this.stringService.bulkMonitorsCreate, - data: monitors, - }); - }, + return res.success({ + msg: this.stringService.bulkMonitorsCreate, + data: monitors, }); }, SERVICE_NAME, "createBulkMonitors" ); - /** - * Checks if the endpoint can be resolved - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.query - The query parameters of the request. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message, and the resolution result. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). - */ - checkEndpointResolution = asyncHandler( - async (req, res, next) => { - await getMonitorURLByQueryValidation.validateAsync(req.query); - const { monitorURL } = req.query; - const parsedUrl = new URL(monitorURL); - const response = await axios.get(parsedUrl, { - timeout: 5000, - validateStatus: () => true, - }); - return res.success({ - status: response.status, - msg: response.statusText, - }); - }, - SERVICE_NAME, - "checkEndpointResolution" - ); - /** - * Deletes a monitor by its ID and also deletes associated checks, alerts, and notifications. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.params - The parameters of the request. - * @property {string} req.params.monitorId - The ID of the monitor to be deleted. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status and a message indicating the deletion of the monitor. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422) or an error in deleting associated records. - */ deleteMonitor = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getMonitorByIdParamValidation.validateAsync(req.params); const monitorId = req.params.monitorId; const teamId = req?.user?.teamId; @@ -393,70 +247,31 @@ class MonitorController { throw new Error("Team ID is required"); } - await this.verifyTeamAccess(teamId, monitorId); + const deletedMonitor = await this.monitorService.deleteMonitor({ teamId, monitorId }); - const monitor = await this.db.deleteMonitor({ monitorId }); - await this.jobQueue.deleteJob(monitor); - await this.db.deleteStatusPagesByMonitorId(monitor._id); - return res.success({ msg: this.stringService.monitorDelete }); + return res.success({ msg: this.stringService.monitorDelete, data: deletedMonitor }); }, SERVICE_NAME, "deleteMonitor" ); - /** - * Deletes all monitors associated with a team. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.headers - The headers of the request. - * @property {string} req.headers.authorization - The authorization header containing the JWT token. - * @param {Object} res - The Express response object. - * @param {function} next - * @returns {Object} The response object with a success status and a message indicating the number of deleted monitors. - * @throws {Error} If there is an error during the deletion process. - */ deleteAllMonitors = asyncHandler( - async (req, res, next) => { + async (req, res) => { const teamId = req?.user?.teamId; - const { monitors, deletedCount } = await this.db.deleteAllMonitors(teamId); - await Promise.all( - monitors.map(async (monitor) => { - try { - await this.jobQueue.deleteJob(monitor); - await this.db.deleteChecks(monitor._id); - await this.db.deletePageSpeedChecksByMonitorId(monitor._id); - await this.db.deleteNotificationsByMonitorId(monitor._id); - } catch (error) { - logger.warn({ - message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, - service: SERVICE_NAME, - method: "deleteAllMonitors", - stack: error.stack, - }); - } - }) - ); + if (!teamId) { + throw new Error("Team ID is required"); + } + + const deletedCount = await this.monitorService.deleteAllMonitors({ teamId }); + return res.success({ msg: `Deleted ${deletedCount} monitors` }); }, SERVICE_NAME, "deleteAllMonitors" ); - /** - * Edits a monitor by its ID, updates its notifications, and updates its job in the job queue. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.params - The parameters of the request. - * @property {string} req.params.monitorId - The ID of the monitor to be edited. - * @property {Object} req.body - The body of the request. - * @property {Array} req.body.notifications - The notifications to be associated with the monitor. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the editing of the monitor, and the edited monitor data. - * @throws {Error} If there is an error during the process, especially if there is a validation error (422). - */ editMonitor = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getMonitorByIdParamValidation.validateAsync(req.params); await editMonitorBodyValidation.validateAsync(req.body); const monitorId = req?.params?.monitorId; @@ -466,11 +281,7 @@ class MonitorController { throw new Error("Team ID is required"); } - await this.verifyTeamAccess(teamId, monitorId); - - const editedMonitor = await this.db.editMonitor(monitorId, req.body); - - await this.jobQueue.updateJob(editedMonitor); + const editedMonitor = await this.monitorService.editMonitor({ teamId, monitorId, body: req.body }); return res.success({ msg: this.stringService.monitorEdit, @@ -481,19 +292,8 @@ class MonitorController { "editMonitor" ); - /** - * Pauses or resumes a monitor based on its current state. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.params - The parameters of the request. - * @property {string} req.params.monitorId - The ID of the monitor to be paused or resumed. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the new state of the monitor, and the updated monitor data. - * @throws {Error} If there is an error during the process. - */ pauseMonitor = asyncHandler( - async (req, res, next) => { + async (req, res) => { await pauseMonitorParamValidation.validateAsync(req.params); const monitorId = req.params.monitorId; @@ -502,10 +302,7 @@ class MonitorController { throw new Error("Team ID is required"); } - await this.verifyTeamAccess(teamId, monitorId); - - const monitor = await this.db.pauseMonitor({ monitorId }); - monitor.isActive === true ? await this.jobQueue.resumeJob(monitor._id, monitor) : await this.jobQueue.pauseJob(monitor); + const monitor = await this.monitorService.pauseMonitor({ teamId, monitorId }); return res.success({ msg: monitor.isActive ? this.stringService.monitorResume : this.stringService.monitorPause, @@ -516,60 +313,28 @@ class MonitorController { "pauseMonitor" ); - /** - * Adds demo monitors for a team. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.headers - The headers of the request. - * @property {string} req.headers.authorization - The authorization header containing the JWT token. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status, a message indicating the addition of demo monitors, and the number of demo monitors added. - * @throws {Error} If there is an error during the process. - */ addDemoMonitors = asyncHandler( - async (req, res, next) => { + async (req, res) => { const { _id, teamId } = req.user; - const demoMonitors = await this.db.addDemoMonitors(_id, teamId); - await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor._id, monitor))); + const demoMonitors = await this.monitorService.addDemoMonitors({ userId: _id, teamId }); return res.success({ msg: this.stringService.monitorDemoAdded, - data: demoMonitors.length, + data: demoMonitors?.length ?? 0, }); }, SERVICE_NAME, "addDemoMonitors" ); - /** - * Sends a test email to verify email delivery functionality. - * @async - * @param {Object} req - The Express request object. - * @property {Object} req.body - The body of the request. - * @property {string} req.body.to - The email address to send the test email to. - * @param {Object} res - The Express response object. - * @param {function} next - The next middleware function. - * @returns {Object} The response object with a success status and the email delivery message ID. - * @throws {Error} If there is an error while sending the test email. - */ sendTestEmail = asyncHandler( - async (req, res, next) => { + async (req, res) => { const { to } = req.body; if (!to || typeof to !== "string") { throw new Error(this.stringService.errorForValidEmailAddress); } - const subject = this.stringService.testEmailSubject; - const context = { testName: "Monitoring System" }; - - const html = await this.emailService.buildEmail("testEmailTemplate", context); - const messageId = await this.emailService.sendEmail(to, subject, html); - - if (!messageId) { - throw createServerError("Failed to send test email."); - } - + const messageId = await this.monitorService.sendTestEmail({ to }); return res.success({ msg: this.stringService.sendTestEmail, data: { messageId }, @@ -585,18 +350,10 @@ class MonitorController { await getMonitorsByTeamIdQueryValidation.validateAsync(req.query); let { limit, type, page, rowsPerPage, filter, field, order } = req.query; - const teamId = req.user.teamId; + const teamId = req?.user?.teamId; + + const monitors = await this.monitorService.getMonitorsByTeamId({ teamId, limit, type, page, rowsPerPage, filter, field, order }); - const monitors = await this.db.getMonitorsByTeamId({ - limit, - type, - page, - rowsPerPage, - filter, - field, - order, - teamId, - }); return res.success({ msg: this.stringService.monitorGetByTeamId, data: monitors, @@ -607,19 +364,19 @@ class MonitorController { ); getMonitorsAndSummaryByTeamId = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getMonitorsByTeamIdParamValidation.validateAsync(req.params); await getMonitorsByTeamIdQueryValidation.validateAsync(req.query); - const { explain } = req; - const { type } = req.query; - const { teamId } = req.user; + const explain = req?.query?.explain; + const type = req?.query?.type; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } + + const result = await this.monitorService.getMonitorsAndSummaryByTeamId({ teamId, type, explain }); - const result = await this.db.getMonitorsAndSummaryByTeamId({ - type, - explain, - teamId, - }); return res.success({ msg: "OK", // TODO data: result, @@ -630,15 +387,19 @@ class MonitorController { ); getMonitorsWithChecksByTeamId = asyncHandler( - async (req, res, next) => { + async (req, res) => { await getMonitorsByTeamIdParamValidation.validateAsync(req.params); await getMonitorsByTeamIdQueryValidation.validateAsync(req.query); - const { explain } = req; + const explain = req?.query?.explain; let { limit, type, page, rowsPerPage, filter, field, order } = req.query; - const { teamId } = req.user; + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); + } - const result = await this.db.getMonitorsWithChecksByTeamId({ + const monitors = await this.monitorService.getMonitorsWithChecksByTeamId({ + teamId, limit, type, page, @@ -646,51 +407,26 @@ class MonitorController { filter, field, order, - teamId, explain, }); + return res.success({ msg: "OK", - data: result, + data: monitors, }); }, SERVICE_NAME, "getMonitorsWithChecksByTeamId" ); - seedDb = asyncHandler( - async (req, res, next) => { - const { _id, teamId } = req.user; - await seedDb(_id, teamId); - res.success({ msg: "Database seeded" }); - }, - SERVICE_NAME, - "seedDb" - ); - exportMonitorsToCSV = asyncHandler( - async (req, res, next) => { - const { teamId } = req.user; - - const monitors = await this.db.getMonitorsByTeamId({ teamId }); - if (!monitors || monitors.length === 0) { - return res.success({ - msg: this.stringService.noMonitorsFound, - data: null, - }); + async (req, res) => { + const teamId = req?.user?.teamId; + if (!teamId) { + throw new Error("Team ID is required"); } - const csvData = monitors?.filteredMonitors?.map((monitor) => ({ - name: monitor.name, - description: monitor.description, - type: monitor.type, - url: monitor.url, - interval: monitor.interval, - port: monitor.port, - ignoreTlsErrors: monitor.ignoreTlsErrors, - isActive: monitor.isActive, - })); - const csv = pkg.unparse(csvData); + const csv = await this.monitorService.exportMonitorsToCSV({ teamId }); return res.file({ data: csv, diff --git a/server/db/models/Monitor.js b/server/db/models/Monitor.js index e8e19ac29..266cebeef 100755 --- a/server/db/models/Monitor.js +++ b/server/db/models/Monitor.js @@ -126,18 +126,22 @@ MonitorSchema.pre("findOneAndDelete", async function (next) { try { const doc = await this.model.findOne(this.getFilter()); - if (doc.type === "pagespeed") { + if (!doc) { + throw new Error("Monitor not found"); + } + + if (doc?.type === "pagespeed") { await PageSpeedCheck.deleteMany({ monitorId: doc._id }); - } else if (doc.type === "hardware") { + } else if (doc?.type === "hardware") { await HardwareCheck.deleteMany({ monitorId: doc._id }); } else { await Check.deleteMany({ monitorId: doc._id }); } // Deal with status pages - await StatusPage.updateMany({ monitors: doc._id }, { $pull: { monitors: doc._id } }); + await StatusPage.updateMany({ monitors: doc?._id }, { $pull: { monitors: doc?._id } }); - await MonitorStats.deleteMany({ monitorId: doc._id.toString() }); + await MonitorStats.deleteMany({ monitorId: doc?._id.toString() }); next(); } catch (error) { next(error); diff --git a/server/db/mongo/modules/monitorModule.js b/server/db/mongo/modules/monitorModule.js index 18cb3c63f..6e2e80f49 100755 --- a/server/db/mongo/modules/monitorModule.js +++ b/server/db/mongo/modules/monitorModule.js @@ -279,7 +279,6 @@ const calculateGroupStats = (group) => { * @throws {Error} */ const getUptimeDetailsById = async ({ monitorId, dateRange, normalize }) => { - const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); try { const dates = getDateRange(dateRange); const formatLookup = { @@ -595,16 +594,16 @@ const createBulkMonitors = async (req) => { * @returns {Promise} * @throws {Error} */ -const deleteMonitor = async ({ monitorId }) => { +const deleteMonitor = async ({ teamId, monitorId }) => { const stringService = ServiceRegistry.get(StringService.SERVICE_NAME); try { - const monitor = await Monitor.findByIdAndDelete(monitorId); + const deletedMonitor = await Monitor.findOneAndDelete({ _id: monitorId, teamId }); - if (!monitor) { + if (!deletedMonitor) { throw new Error(stringService.getDbFindMonitorById(monitorId)); } - return monitor; + return deletedMonitor; } catch (error) { error.service = SERVICE_NAME; error.method = "deleteMonitor"; @@ -654,9 +653,9 @@ const deleteMonitorsByUserId = async (userId) => { * @returns {Promise} * @throws {Error} */ -const editMonitor = async (candidateId, candidateMonitor) => { +const editMonitor = async ({ monitorId, body }) => { try { - const editedMonitor = await Monitor.findByIdAndUpdate(candidateId, candidateMonitor, { + const editedMonitor = await Monitor.findByIdAndUpdate(monitorId, body, { new: true, }); return editedMonitor; diff --git a/server/db/mongo/modules/monitorModuleQueries.js b/server/db/mongo/modules/monitorModuleQueries.js index 422362f30..d96e128fa 100755 --- a/server/db/mongo/modules/monitorModuleQueries.js +++ b/server/db/mongo/modules/monitorModuleQueries.js @@ -138,6 +138,7 @@ const buildUptimeDetailsPipeline = (monitorId, dates, dateString) => { { $project: { _id: 1, + teamId: 1, name: 1, status: 1, interval: 1, diff --git a/server/index.js b/server/index.js index 9c7778a99..541f9ae14 100755 --- a/server/index.js +++ b/server/index.js @@ -2,6 +2,7 @@ import path from "path"; import fs from "fs"; import swaggerUi from "swagger-ui-express"; import jwt from "jsonwebtoken"; +import papaparse from "papaparse"; import express from "express"; import helmet from "helmet"; @@ -63,6 +64,7 @@ import CheckService from "./service/business/checkService.js"; import DiagnosticService from "./service/business/diagnosticService.js"; import InviteService from "./service/business/inviteService.js"; import MaintenanceWindowService from "./service/business/maintenanceWindowService.js"; +import MonitorService from "./service/business/monitorService.js"; //Network service and dependencies import NetworkService from "./service/infrastructure/networkService.js"; @@ -185,32 +187,6 @@ const startApp = async () => { const redisService = new RedisService({ Redis: IORedis, logger }); - // Business services - const userService = new UserService({ - db, - emailService, - settingsService, - logger, - stringService, - jwt, - }); - const checkService = new CheckService({ - db, - settingsService, - stringService, - }); - const diagnosticService = new DiagnosticService(); - const inviteService = new InviteService({ - db, - settingsService, - emailService, - stringService, - }); - const maintenanceWindowService = new MaintenanceWindowService({ - db, - settingsService, - stringService, - }); // const jobQueueHelper = new JobQueueHelper({ // redisService, // Queue, @@ -256,6 +232,41 @@ const startApp = async () => { helper: superSimpleQueueHelper, }); + // Business services + const userService = new UserService({ + db, + emailService, + settingsService, + logger, + stringService, + jwt, + }); + const checkService = new CheckService({ + db, + settingsService, + stringService, + }); + const diagnosticService = new DiagnosticService(); + const inviteService = new InviteService({ + db, + settingsService, + emailService, + stringService, + }); + const maintenanceWindowService = new MaintenanceWindowService({ + db, + settingsService, + stringService, + }); + const monitorService = new MonitorService({ + db, + settingsService, + jobQueue: superSimpleQueue, + stringService, + emailService, + papaparse, + logger, + }); // Register services // ServiceRegistry.register(JobQueue.SERVICE_NAME, jobQueue); // ServiceRegistry.register(JobQueue.SERVICE_NAME, pulseQueue); @@ -273,6 +284,7 @@ const startApp = async () => { ServiceRegistry.register(CheckService.SERVICE_NAME, checkService); ServiceRegistry.register(InviteService.SERVICE_NAME, inviteService); ServiceRegistry.register(MaintenanceWindowService.SERVICE_NAME, maintenanceWindowService); + ServiceRegistry.register(MonitorService.SERVICE_NAME, monitorService); await translationService.initialize(); @@ -296,13 +308,14 @@ const startApp = async () => { userService: ServiceRegistry.get(UserService.SERVICE_NAME), }); - const monitorController = new MonitorController( - ServiceRegistry.get(MongoDB.SERVICE_NAME), - ServiceRegistry.get(SettingsService.SERVICE_NAME), - ServiceRegistry.get(JobQueue.SERVICE_NAME), - ServiceRegistry.get(StringService.SERVICE_NAME), - ServiceRegistry.get(EmailService.SERVICE_NAME) - ); + const monitorController = new MonitorController({ + db: ServiceRegistry.get(MongoDB.SERVICE_NAME), + settingsService: ServiceRegistry.get(SettingsService.SERVICE_NAME), + jobQueue: ServiceRegistry.get(JobQueue.SERVICE_NAME), + stringService: ServiceRegistry.get(StringService.SERVICE_NAME), + emailService: ServiceRegistry.get(EmailService.SERVICE_NAME), + monitorService: ServiceRegistry.get(MonitorService.SERVICE_NAME), + }); const settingsController = new SettingsController({ db: ServiceRegistry.get(MongoDB.SERVICE_NAME), diff --git a/server/routes/monitorRoute.js b/server/routes/monitorRoute.js index 15553fc1c..0124c209d 100755 --- a/server/routes/monitorRoute.js +++ b/server/routes/monitorRoute.js @@ -2,9 +2,6 @@ import { Router } from "express"; import { isAllowed } from "../middleware/isAllowed.js"; import multer from "multer"; import { fetchMonitorCertificate } from "../controllers/controllerUtils.js"; -import Monitor from "../db/models/Monitor.js"; -import { verifyOwnership } from "../middleware/verifyOwnership.js"; -import { verifyTeamAccess } from "../middleware/verifyTeamAccess.js"; const upload = multer({ storage: multer.memoryStorage(), // Store file in memory as Buffer }); @@ -33,7 +30,6 @@ class MonitorRoutes { this.router.get("/stats/:monitorId", this.monitorController.getMonitorStatsById); // Util routes - this.router.get("/resolution/url", isAllowed(["admin", "superadmin"]), this.monitorController.checkEndpointResolution); this.router.get("/certificate/:monitorId", (req, res, next) => { this.monitorController.getMonitorCertificate(req, res, next, fetchMonitorCertificate); }); @@ -46,7 +42,6 @@ class MonitorRoutes { // Other static routes this.router.post("/demo", isAllowed(["admin", "superadmin"]), this.monitorController.addDemoMonitors); this.router.get("/export", isAllowed(["admin", "superadmin"]), this.monitorController.exportMonitorsToCSV); - this.router.post("/seed", isAllowed(["superadmin"]), this.monitorController.seedDb); this.router.post("/bulk", isAllowed(["admin", "superadmin"]), upload.single("csvFile"), this.monitorController.createBulkMonitors); this.router.post("/test-email", isAllowed(["admin", "superadmin"]), this.monitorController.sendTestEmail); diff --git a/server/service/business/monitorService.js b/server/service/business/monitorService.js new file mode 100644 index 000000000..d0e8f92ca --- /dev/null +++ b/server/service/business/monitorService.js @@ -0,0 +1,265 @@ +import { createMonitorsBodyValidation } from "../../validation/joi.js"; +import { createServerError } from "../../utils/errorUtils.js"; +import logger from "../../utils/logger.js"; + +const SERVICE_NAME = "MonitorService"; +class MonitorService { + SERVICE_NAME = SERVICE_NAME; + + constructor({ db, settingsService, jobQueue, stringService, emailService, papaparse }) { + this.db = db; + this.settingsService = settingsService; + this.jobQueue = jobQueue; + this.stringService = stringService; + this.emailService = emailService; + this.papaparse = papaparse; + this.logger = logger; + } + + verifyTeamAccess = async ({ teamId, monitorId }) => { + const monitor = await this.db.getMonitorById(monitorId); + if (!monitor?.teamId?.equals(teamId)) { + const error = new Error("Unauthorized"); + error.status = 403; + throw error; + } + }; + + getAllMonitors = async () => { + const monitors = await this.db.getAllMonitors(); + return monitors; + }; + + getUptimeDetailsById = async ({ teamId, monitorId, dateRange, normalize }) => { + await this.verifyTeamAccess({ teamId, monitorId }); + const data = await this.db.getUptimeDetailsById({ + monitorId, + dateRange, + normalize, + }); + + return data; + }; + + getMonitorStatsById = async ({ teamId, monitorId, limit, sortOrder, dateRange, numToDisplay, normalize }) => { + await this.verifyTeamAccess({ teamId, monitorId }); + const monitorStats = await this.db.getMonitorStatsById({ + monitorId, + limit, + sortOrder, + dateRange, + numToDisplay, + normalize, + }); + + return monitorStats; + }; + + getHardwareDetailsById = async ({ teamId, monitorId, dateRange }) => { + await this.verifyTeamAccess({ teamId, monitorId }); + const monitor = await this.db.getHardwareDetailsById({ monitorId, dateRange }); + + return monitor; + }; + + getMonitorById = async ({ teamId, monitorId }) => { + await this.verifyTeamAccess({ teamId, monitorId }); + const monitor = await this.db.getMonitorById(monitorId); + + return monitor; + }; + + createMonitor = async ({ teamId, userId, body }) => { + const monitor = await this.db.createMonitor({ + body, + teamId, + userId, + }); + + this.jobQueue.addJob(monitor._id, monitor); + }; + + createBulkMonitors = async ({ fileData, userId, teamId }) => { + const { parse } = this.papaparse; + + return new Promise((resolve, reject) => { + parse(fileData, { + header: true, + skipEmptyLines: true, + transform: (value, header) => { + if (value === "") return undefined; // Empty fields become undefined + + // Handle 'port' and 'interval' fields, check if they're valid numbers + if (["port", "interval"].includes(header)) { + const num = parseInt(value, 10); + if (isNaN(num)) { + throw new Error(`${header} should be a valid number, got: ${value}`); + } + return num; + } + + return value; + }, + complete: async ({ data, errors }) => { + try { + if (errors.length > 0) { + throw createServerError("Error parsing CSV"); + } + + if (!data || data.length === 0) { + throw createServerError("CSV file contains no data rows"); + } + + const enrichedData = data.map((monitor) => ({ + userId, + teamId, + ...monitor, + description: monitor.description || monitor.name || monitor.url, + name: monitor.name || monitor.url, + type: monitor.type || "http", + })); + + await createMonitorsBodyValidation.validateAsync(enrichedData); + + const monitors = await this.db.createBulkMonitors(enrichedData); + + await Promise.all( + monitors.map(async (monitor) => { + this.jobQueue.addJob(monitor._id, monitor); + }) + ); + + resolve(monitors); + } catch (error) { + reject(error); + } + }, + }); + }); + }; + + deleteMonitor = async ({ teamId, monitorId }) => { + await this.verifyTeamAccess({ teamId, monitorId }); + const monitor = await this.db.deleteMonitor({ teamId, monitorId }); + await this.jobQueue.deleteJob(monitor); + await this.db.deleteStatusPagesByMonitorId(monitor._id); + return monitor; + }; + + deleteAllMonitors = async ({ teamId }) => { + const { monitors, deletedCount } = await this.db.deleteAllMonitors(teamId); + await Promise.all( + monitors.map(async (monitor) => { + try { + await this.jobQueue.deleteJob(monitor); + await this.db.deleteChecks(monitor._id); + await this.db.deletePageSpeedChecksByMonitorId(monitor._id); + await this.db.deleteNotificationsByMonitorId(monitor._id); + } catch (error) { + logger.warn({ + message: `Error deleting associated records for monitor ${monitor._id} with name ${monitor.name}`, + service: SERVICE_NAME, + method: "deleteAllMonitors", + stack: error.stack, + }); + } + }) + ); + return deletedCount; + }; + + editMonitor = async ({ teamId, monitorId, body }) => { + await this.verifyTeamAccess({ teamId, monitorId }); + const editedMonitor = await this.db.editMonitor({ monitorId, body }); + await this.jobQueue.updateJob(editedMonitor); + }; + + pauseMonitor = async ({ teamId, monitorId }) => { + await this.verifyTeamAccess({ teamId, monitorId }); + const monitor = await this.db.pauseMonitor({ monitorId }); + monitor.isActive === true ? await this.jobQueue.resumeJob(monitor._id, monitor) : await this.jobQueue.pauseJob(monitor); + return monitor; + }; + + addDemoMonitors = async ({ userId, teamId }) => { + const demoMonitors = await this.db.addDemoMonitors(userId, teamId); + await Promise.all(demoMonitors.map((monitor) => this.jobQueue.addJob(monitor._id, monitor))); + return demoMonitors; + }; + + sendTestEmail = async ({ to }) => { + const subject = this.stringService.testEmailSubject; + const context = { testName: "Monitoring System" }; + + const html = await this.emailService.buildEmail("testEmailTemplate", context); + const messageId = await this.emailService.sendEmail(to, subject, html); + + if (!messageId) { + throw createServerError("Failed to send test email."); + } + + return messageId; + }; + + getMonitorsByTeamId = async ({ teamId, limit, type, page, rowsPerPage, filter, field, order }) => { + const monitors = await this.db.getMonitorsByTeamId({ + limit, + type, + page, + rowsPerPage, + filter, + field, + order, + teamId, + }); + return monitors; + }; + + getMonitorsAndSummaryByTeamId = async ({ teamId, type, explain }) => { + const result = await this.db.getMonitorsAndSummaryByTeamId({ + type, + explain, + teamId, + }); + return result; + }; + + getMonitorsWithChecksByTeamId = async ({ teamId, limit, type, page, rowsPerPage, filter, field, order, explain }) => { + const result = await this.db.getMonitorsWithChecksByTeamId({ + limit, + type, + page, + rowsPerPage, + filter, + field, + order, + teamId, + explain, + }); + return result; + }; + + exportMonitorsToCSV = async ({ teamId }) => { + const monitors = await this.monitorService.getMonitorsByTeamId({ teamId }); + + if (!monitors || monitors.length === 0) { + throw new Error("No monitors to export"); + } + + const csvData = monitors?.filteredMonitors?.map((monitor) => ({ + name: monitor.name, + description: monitor.description, + type: monitor.type, + url: monitor.url, + interval: monitor.interval, + port: monitor.port, + ignoreTlsErrors: monitor.ignoreTlsErrors, + isActive: monitor.isActive, + })); + + const csv = this.papaparse.unparse(csvData); + return csv; + }; +} + +export default MonitorService; diff --git a/server/utils/errorUtils.js b/server/utils/errorUtils.js index f913746b3..7d9a2959d 100644 --- a/server/utils/errorUtils.js +++ b/server/utils/errorUtils.js @@ -65,12 +65,16 @@ export const asyncHandler = (fn, serviceName, methodName) => { return next(appError); } + if (error.status) { + const appError = createError(error.message, error.status, serviceName, methodName, { originalError: error.message, stack: error.stack }); + return next(appError); + } + // For unknown errors, create a server error const appError = createServerError(error.message || "An unexpected error occurred", { originalError: error.message, stack: error.stack }); appError.service = serviceName; appError.method = methodName; appError.stack = error.stack; // Preserve original stack - return next(appError); } };