diff --git a/server/src/config/controllers.js b/server/src/config/controllers.js index 927acd1a2..746a447a3 100644 --- a/server/src/config/controllers.js +++ b/server/src/config/controllers.js @@ -14,6 +14,7 @@ import LogController from "../controllers/v1/logController.js"; import StatusPageController from "../controllers/v1/statusPageController.js"; import NotificationController from "../controllers/v1/notificationController.js"; import DiagnosticController from "../controllers/v1/diagnosticController.js"; +import IncidentController from "../controllers/v1/incidentController.js"; // V2 Controllers import AuthControllerV2 from "../controllers/v2/AuthController.js"; @@ -70,6 +71,10 @@ export const initializeControllers = (services) => { diagnosticService: services.diagnosticService, }); + controllers.incidentController = new IncidentController(commonDependencies, { + incidentService: services.incidentService, + }); + //V2 controllers.authControllerV2 = new AuthControllerV2(services.authServiceV2, services.inviteServiceV2); controllers.inviteControllerV2 = new InviteControllerV2(services.inviteServiceV2); diff --git a/server/src/config/routes.js b/server/src/config/routes.js index 13b369262..5dace493c 100644 --- a/server/src/config/routes.js +++ b/server/src/config/routes.js @@ -13,6 +13,8 @@ import LogRoutes from "../routes/v1/logRoutes.js"; import DiagnosticRoutes from "../routes/v1//diagnosticRoute.js"; import NotificationRoutes from "../routes/v1/notificationRoute.js"; +import IncidentRoutes from "../routes/v1/incidentRoute.js"; + //V2 import AuthRoutesV2 from "../routes/v2/auth.js"; import InviteRoutesV2 from "../routes/v2/invite.js"; @@ -34,6 +36,7 @@ export const setupRoutes = (app, controllers) => { const statusPageRoutes = new StatusPageRoutes(controllers.statusPageController); const notificationRoutes = new NotificationRoutes(controllers.notificationController); const diagnosticRoutes = new DiagnosticRoutes(controllers.diagnosticController); + const incidentRoutes = new IncidentRoutes(controllers.incidentController); app.use("/api/v1/auth", authApiLimiter, authRoutes.getRouter()); app.use("/api/v1/monitors", verifyJWT, monitorRoutes.getRouter()); @@ -46,6 +49,7 @@ export const setupRoutes = (app, controllers) => { app.use("/api/v1/status-page", statusPageRoutes.getRouter()); app.use("/api/v1/notifications", verifyJWT, notificationRoutes.getRouter()); app.use("/api/v1/diagnostic", verifyJWT, diagnosticRoutes.getRouter()); + app.use("/api/v1/incidents", verifyJWT, incidentRoutes.getRouter()); // V2 const authRoutesV2 = new AuthRoutesV2(controllers.authControllerV2); diff --git a/server/src/config/services.js b/server/src/config/services.js index c1679868d..004acc885 100644 --- a/server/src/config/services.js +++ b/server/src/config/services.js @@ -17,6 +17,7 @@ import DiagnosticService from "../service/v1/business/diagnosticService.js"; import InviteService from "../service/v1/business/inviteService.js"; import MaintenanceWindowService from "../service/v1/business/maintenanceWindowService.js"; import MonitorService from "../service/v1/business/monitorService.js"; +import IncidentService from "../service/v1/business/incidentService.js"; import papaparse from "papaparse"; import axios from "axios"; import got from "got"; @@ -57,6 +58,7 @@ import MonitorStats from "../db/v1/models/MonitorStats.js"; import Notification from "../db/v1/models/Notification.js"; import RecoveryToken from "../db/v1/models/RecoveryToken.js"; import AppSettings from "../db/v1/models/AppSettings.js"; +import Incident from "../db/v1/models/Incident.js"; import InviteModule from "../db/v1/modules/inviteModule.js"; import CheckModule from "../db/v1/modules/checkModule.js"; @@ -67,6 +69,7 @@ import MonitorModule from "../db/v1/modules/monitorModule.js"; import NotificationModule from "../db/v1/modules/notificationModule.js"; import RecoveryModule from "../db/v1/modules/recoveryModule.js"; import SettingsModule from "../db/v1/modules/settingsModule.js"; +import IncidentModule from "../db/v1/modules/incidentModule.js"; // V2 Business import AuthServiceV2 from "../service/v2/business/AuthService.js"; @@ -120,6 +123,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService const notificationModule = new NotificationModule({ Notification, Monitor }); const recoveryModule = new RecoveryModule({ User, RecoveryToken, crypto, stringService }); const settingsModule = new SettingsModule({ AppSettings }); + const incidentModule = new IncidentModule({ logger, Incident, Monitor, User }); const db = new MongoDB({ logger, @@ -133,6 +137,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService notificationModule, recoveryModule, settingsModule, + incidentModule, }); await db.connect(); @@ -152,8 +157,18 @@ export const initializeServices = async ({ logger, envSettings, settingsService settingsService, }); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); - const bufferService = new BufferService({ db, logger, envSettings }); - const statusService = new StatusService({ db, logger, buffer: bufferService }); + const errorService = new ErrorService(); + + const incidentService = new IncidentService({ + db, + logger, + errorService, + stringService, + }); + + const bufferService = new BufferService({ db, logger, envSettings, incidentService }); + + const statusService = new StatusService({ db, logger, buffer: bufferService, incidentService }); const notificationUtils = new NotificationUtils({ stringService, @@ -169,8 +184,6 @@ export const initializeServices = async ({ logger, envSettings, settingsService notificationUtils, }); - const errorService = new ErrorService(); - const superSimpleQueueHelper = new SuperSimpleQueueHelper({ db, logger, @@ -275,6 +288,7 @@ export const initializeServices = async ({ logger, envSettings, settingsService inviteService, maintenanceWindowService, monitorService, + incidentService, errorService, logger, //v2 diff --git a/server/src/controllers/v1/incidentController.js b/server/src/controllers/v1/incidentController.js new file mode 100644 index 000000000..27bb93ee8 --- /dev/null +++ b/server/src/controllers/v1/incidentController.js @@ -0,0 +1,184 @@ +import BaseController from "./baseController.js"; + +const SERVICE_NAME = "incidentController"; + +/** + * Incident Controller + * + * Handles all incident-related HTTP requests including retrieving incidents, + * resolving incidents manually, and getting incident summaries. + * + * @class IncidentController + * @description Manages incident operations and tracking + */ +class IncidentController extends BaseController { + static SERVICE_NAME = SERVICE_NAME; + + /** + * Creates an instance of IncidentController. + * + * @param {Object} commonDependencies - Common dependencies injected into the controller + * @param {Object} dependencies - The dependencies required by the controller + * @param {Object} dependencies.incidentService - Incident business logic service + */ + constructor(commonDependencies, { incidentService }) { + super(commonDependencies); + this.incidentService = incidentService; + } + + get serviceName() { + return IncidentController.SERVICE_NAME; + } + + /** + * Retrieves all incidents for the current user's team with filtering and pagination. + * + * @async + * @function getIncidentsByTeam + * @param {Object} req - Express request object + * @param {Object} req.query - Query parameters for filtering and pagination + * @param {string} [req.query.sortOrder] - Sort order (asc/desc) + * @param {string} [req.query.dateRange] - Date range filter + * @param {string} [req.query.filter] - General filter string + * @param {number} [req.query.page] - Page number for pagination + * @param {number} [req.query.rowsPerPage] - Number of rows per page + * @param {boolean} [req.query.status] - Filter by incident status (true=active, false=resolved) + * @param {string} [req.query.monitorId] - Filter by monitor ID + * @param {string} [req.query.resolutionType] - Filter by resolution type (automatic/manual) + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with incidents data + * @throws {Error} 422 - Validation error if query parameters are invalid + * @example + * GET /incidents/team?page=1&rowsPerPage=20&status=true&filter=active + * // Requires JWT authentication + */ + getIncidentsByTeam = this.asyncHandler( + async (req, res) => { + const result = await this.incidentService.getIncidentsByTeam({ + teamId: req?.user?.teamId, + query: req?.query, + }); + + return res.success({ + msg: "Incidents retrieved successfully", + data: result, + }); + }, + SERVICE_NAME, + "getIncidentsByTeam" + ); + + /** + * Retrieves a summary of incidents for the current user's team. + * + * @async + * @function getIncidentSummary + * @param {Object} req - Express request object + * @param {Object} req.query - Query parameters + * @param {string} [req.query.dateRange] - Date range filter + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with incidents summary + * @example + * GET /incidents/summary?dateRange=week + * // Requires JWT authentication + */ + getIncidentSummary = this.asyncHandler( + async (req, res) => { + const summary = await this.incidentService.getIncidentSummary({ + teamId: req?.user?.teamId, + query: req?.query, + }); + + return res.success({ + msg: "Incident summary retrieved successfully", + data: summary, + }); + }, + SERVICE_NAME, + "getIncidentSummary" + ); + + /** + * Retrieves a specific incident by ID. + * + * @async + * @function getIncidentById + * @param {Object} req - Express request object + * @param {Object} req.params - URL parameters + * @param {string} req.params.incidentId - ID of the incident to retrieve + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with incident data + * @throws {Error} 404 - Not found if incident doesn't exist + * @throws {Error} 403 - Forbidden if user doesn't have access to incident + * @example + * GET /incidents/507f1f77bcf86cd799439011 + * // Requires JWT authentication + */ + getIncidentById = this.asyncHandler( + async (req, res) => { + const incident = await this.incidentService.getIncidentById({ + incidentId: req?.params?.incidentId, + teamId: req?.user?.teamId, + }); + + return res.success({ + msg: "Incident retrieved successfully", + data: incident, + }); + }, + SERVICE_NAME, + "getIncidentById" + ); + + /** + * Manually resolves a specific incident by ID. + * + * @async + * @function resolveIncidentManually + * @param {Object} req - Express request object + * @param {Object} req.params - URL parameters + * @param {string} req.params.incidentId - ID of the incident to resolve + * @param {Object} req.body - Request body containing resolution data + * @param {string} [req.body.comment] - Optional comment about the resolution + * @param {Object} req.user - Current authenticated user (from JWT) + * @param {string} req.user.teamId - User's team ID + * @param {string} req.user._id - User's ID + * @param {Object} res - Express response object + * @returns {Promise} Success response with updated incident data + * @throws {Error} 422 - Validation error if request body is invalid + * @throws {Error} 404 - Not found if incident doesn't exist + * @throws {Error} 403 - Forbidden if user doesn't have access to incident + * @throws {Error} 400 - Bad request if incident is already resolved + * @example + * PUT /incidents/507f1f77bcf86cd799439011/resolve + * { + * "comment": "Issue resolved by restarting the service" + * } + * // Requires JWT authentication + */ + resolveIncidentManually = this.asyncHandler( + async (req, res) => { + const resolvedIncident = await this.incidentService.resolveIncidentManually({ + incidentId: req?.params?.incidentId, + userId: req?.user?._id, + teamId: req?.user?.teamId, + comment: req?.body?.comment, + }); + + return res.success({ + msg: "Incident resolved successfully", + data: resolvedIncident, + }); + }, + SERVICE_NAME, + "resolveIncidentManually" + ); +} + +export default IncidentController; diff --git a/server/src/db/v1/MongoDB.js b/server/src/db/v1/MongoDB.js index 3bdae3525..115c7e9ee 100755 --- a/server/src/db/v1/MongoDB.js +++ b/server/src/db/v1/MongoDB.js @@ -19,6 +19,7 @@ class MongoDB { pageSpeedCheckModule, recoveryModule, settingsModule, + incidentModule, }) { this.logger = logger; this.envSettings = envSettings; @@ -34,6 +35,7 @@ class MongoDB { this.settingsModule = settingsModule; this.statusPageModule = statusPageModule; this.networkCheckModule = networkCheckModule; + this.incidentModule = incidentModule; } get serviceName() { diff --git a/server/src/db/v1/models/Incident.js b/server/src/db/v1/models/Incident.js new file mode 100644 index 000000000..a9477301b --- /dev/null +++ b/server/src/db/v1/models/Incident.js @@ -0,0 +1,76 @@ +import mongoose from "mongoose"; + +const IncidentSchema = mongoose.Schema( + { + monitorId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Monitor", + required: true, + immutable: true, + index: true, + }, + teamId: { + type: mongoose.Schema.Types.ObjectId, + ref: "Team", + required: true, + immutable: true, + index: true, + }, + startTime: { + type: Date, + required: true, + immutable: true, + }, + endTime: { + type: Date, + default: null, + }, + status: { + type: Boolean, + default: true, + index: true, + }, + message: { + type: String, + default: null, + }, + statusCode: { + type: Number, + index: true, + default: null, + }, + resolutionType: { + type: String, + enum: ["automatic", "manual"], + default: null, + }, + resolvedBy: { + type: mongoose.Schema.Types.ObjectId, + ref: "User", + default: null, + }, + comment: { + type: String, + default: null, + }, + checks: [ + { + type: mongoose.Schema.Types.ObjectId, + ref: "Check", + }, + ], + }, + { timestamps: true } +); + +IncidentSchema.index({ monitorId: 1, status: 1 }); +IncidentSchema.index({ teamId: 1, status: 1 }); +IncidentSchema.index({ teamId: 1, startTime: -1 }); +IncidentSchema.index({ status: 1, startTime: -1 }); +IncidentSchema.index({ resolutionType: 1, status: 1 }); +IncidentSchema.index({ resolvedBy: 1, status: 1 }); +IncidentSchema.index({ createdAt: -1 }); + +const Incident = mongoose.model("Incident", IncidentSchema); + +export default Incident; diff --git a/server/src/db/v1/modules/incidentModule.js b/server/src/db/v1/modules/incidentModule.js new file mode 100644 index 000000000..c007853d4 --- /dev/null +++ b/server/src/db/v1/modules/incidentModule.js @@ -0,0 +1,305 @@ +import { ObjectId } from "mongodb"; + +const SERVICE_NAME = "incidentModule"; + +const dateRangeLookup = { + recent: new Date(new Date().setDate(new Date().getDate() - 2)), + hour: new Date(new Date().setHours(new Date().getHours() - 1)), + day: new Date(new Date().setDate(new Date().getDate() - 1)), + week: new Date(new Date().setDate(new Date().getDate() - 7)), + month: new Date(new Date().setMonth(new Date().getMonth() - 1)), + all: undefined, +}; + +class IncidentModule { + constructor({ logger, Incident, Monitor, User }) { + this.logger = logger; + this.Incident = Incident; + this.Monitor = Monitor; + this.User = User; + } + + createIncident = async (incidentData) => { + try { + const incident = await this.Incident.create(incidentData); + return incident; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createIncident"; + throw error; + } + }; + + createIncidents = async (incidents) => { + try { + await this.Incident.insertMany(incidents, { ordered: false }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "createIncidents"; + throw error; + } + }; + + getActiveIncidentByMonitor = async (monitorId) => { + try { + return await this.Incident.findOne({ monitorId: new ObjectId(monitorId), status: true }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getActiveIncidentByMonitor"; + throw error; + } + }; + + getActiveIncidentsByMonitors = async (monitorIds) => { + try { + if (!monitorIds || monitorIds.length === 0) { + return new Map(); + } + + const objectIds = monitorIds.map((id) => new ObjectId(id)); + const incidents = await this.Incident.find({ + monitorId: { $in: objectIds }, + status: true, + }); + + const map = new Map(); + incidents.forEach((incident) => { + const monitorIdStr = incident.monitorId.toString(); + map.set(monitorIdStr, incident); + }); + + return map; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getActiveIncidentsByMonitors"; + throw error; + } + }; + + getLastManuallyResolvedIncident = async (monitorId) => { + try { + return await this.Incident.findOne({ + monitorId: new ObjectId(monitorId), + status: false, + resolutionType: "manual", + }) + .sort({ endTime: -1 }) + .limit(1); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getLastManuallyResolvedIncident"; + throw error; + } + }; + + getIncidentById = async (incidentId) => { + try { + return await this.Incident.findById(incidentId).populate("monitorId", "name type url").populate("resolvedBy", "name email"); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getIncidentById"; + throw error; + } + }; + + resolveIncident = async (incidentId, { resolutionType = "automatic", resolvedBy = null, comment = null, endTime = new Date() } = {}) => { + try { + const update = { + status: false, + endTime, + resolutionType, + resolvedBy, + ...(comment !== null && { comment }), + }; + const incident = await this.Incident.findOneAndUpdate({ _id: new ObjectId(incidentId) }, { $set: update }, { new: true }); + + if (!incident) { + throw new Error("Incident not found"); + } + + return incident; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "resolveIncident"; + throw error; + } + }; + + addCheckToIncident = async (incidentId, checkId) => { + try { + const incident = await this.Incident.findOneAndUpdate( + { _id: new ObjectId(incidentId) }, + { $addToSet: { checks: new ObjectId(checkId) } }, + { new: true } + ); + + if (!incident) { + throw new Error("Incident not found"); + } + + return incident; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "addCheckToIncident"; + throw error; + } + }; + + addChecksToIncidentsBatch = async (operations) => { + try { + if (!operations || operations.length === 0) { + return; + } + + const bulkOps = operations.map((op) => ({ + updateOne: { + filter: { _id: new ObjectId(op.incidentId) }, + update: { $addToSet: { checks: new ObjectId(op.checkId) } }, + }, + })); + + await this.Incident.bulkWrite(bulkOps, { ordered: false }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "addChecksToIncidentsBatch"; + throw error; + } + }; + + getIncidentsByTeam = async ({ teamId, sortOrder, dateRange, page, rowsPerPage, status, monitorId, resolutionType }) => { + try { + page = Number.isFinite(parseInt(page)) ? parseInt(page) : 0; + rowsPerPage = Number.isFinite(parseInt(rowsPerPage)) ? parseInt(rowsPerPage) : 20; + + let statusBoolean = undefined; + if (status !== undefined && status !== null) { + if (typeof status === "string") { + statusBoolean = status === "true"; + } else if (typeof status === "boolean") { + statusBoolean = status; + } + } + + const matchStage = { + teamId: new ObjectId(teamId), + ...(statusBoolean !== undefined && { status: statusBoolean }), + ...(monitorId && { monitorId: new ObjectId(monitorId) }), + ...(resolutionType && { resolutionType }), + ...(dateRangeLookup[dateRange] && { + startTime: { + $gte: dateRangeLookup[dateRange], + }, + }), + }; + + sortOrder = sortOrder === "asc" ? 1 : -1; + + const skip = page * rowsPerPage; + + const incidents = await this.Incident.aggregate([ + { $match: matchStage }, + { $sort: { startTime: sortOrder } }, + { + $facet: { + summary: [{ $count: "incidentsCount" }], + incidents: [{ $skip: skip }, { $limit: rowsPerPage }], + }, + }, + { + $project: { + incidentsCount: { + $ifNull: [{ $arrayElemAt: ["$summary.incidentsCount", 0] }, 0], + }, + incidents: { + $ifNull: ["$incidents", []], + }, + }, + }, + ]); + + return incidents[0]; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getIncidentsByTeam"; + throw error; + } + }; + + getIncidentSummary = async ({ teamId, dateRange }) => { + try { + const matchStage = { + teamId: new ObjectId(teamId), + ...(dateRangeLookup[dateRange] && { + startTime: { + $gte: dateRangeLookup[dateRange], + }, + }), + }; + + const summary = await this.Incident.aggregate([ + { $match: matchStage }, + { + $group: { + _id: "$status", + count: { $sum: 1 }, + manualResolutions: { + $sum: { + $cond: [{ $eq: ["$resolutionType", "manual"] }, 1, 0], + }, + }, + }, + }, + ]); + + const result = { + total: 0, + active: 0, + resolved: 0, + manual: 0, + }; + + summary.forEach((item) => { + result.total += item.count; + if (item._id === true) { + result.active = item.count; + } + if (item._id === false) { + result.resolved = item.count; + } + result.manual += item.manualResolutions; + }); + + return result; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getIncidentSummary"; + throw error; + } + }; + + deleteIncidentsByMonitor = async (monitorId) => { + try { + const result = await this.Incident.deleteMany({ monitorId: new ObjectId(monitorId) }); + return result.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteIncidentsByMonitor"; + throw error; + } + }; + + deleteIncidentsByTeamId = async (teamId) => { + try { + const teamMonitors = await this.Monitor.find({ teamId }, { _id: 1 }); + const monitorIds = teamMonitors.map((monitor) => monitor._id); + const deleteResult = await this.Incident.deleteMany({ monitorId: { $in: monitorIds } }); + return deleteResult.deletedCount; + } catch (error) { + error.service = SERVICE_NAME; + error.method = "deleteIncidentsByTeamId"; + throw error; + } + }; +} + +export default IncidentModule; diff --git a/server/src/routes/v1/incidentRoute.js b/server/src/routes/v1/incidentRoute.js new file mode 100644 index 000000000..6825ce21e --- /dev/null +++ b/server/src/routes/v1/incidentRoute.js @@ -0,0 +1,26 @@ +import { Router } from "express"; +import { isAllowed } from "../../middleware/v1/isAllowed.js"; + +class IncidentRoutes { + constructor(incidentController) { + this.router = Router(); + this.incidentController = incidentController; + this.initRoutes(); + } + + initRoutes() { + // Team routes + this.router.get("/team", this.incidentController.getIncidentsByTeam); + this.router.get("/team/summary", this.incidentController.getIncidentSummary); + + // Individual incident routes + this.router.get("/:incidentId", this.incidentController.getIncidentById); + this.router.put("/:incidentId/resolve", isAllowed(["admin", "superadmin"]), this.incidentController.resolveIncidentManually); + } + + getRouter() { + return this.router; + } +} + +export default IncidentRoutes; diff --git a/server/src/service/infrastructure/notes b/server/src/service/infrastructure/notes new file mode 100644 index 000000000..ce5a77ddb --- /dev/null +++ b/server/src/service/infrastructure/notes @@ -0,0 +1,83 @@ +import { Typography, Select } from "@mui/material"; +import MenuItem from "@mui/material/MenuItem"; +import type { SelectProps } from "@mui/material/Select"; +import { useTheme } from "@mui/material/styles"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; + +export const SelectInput: React.FC = ({ ...props }) => { + const theme = useTheme(); + return ( +