From 0c3fdbc539e4b6bced9a804edef4a31600bef4a3 Mon Sep 17 00:00:00 2001 From: karenvicent Date: Thu, 6 Nov 2025 17:06:33 -0500 Subject: [PATCH 1/2] feat: add incident model, module and service with StatusService integration - Create Incident model with resolution tracking fields - Implement IncidentModule for data access - Implement IncidentService with business logic - Integrate with StatusService to auto-create/resolve incidents on status change --- server/src/config/services.js | 20 +- server/src/db/v1/MongoDB.js | 2 + server/src/db/v1/models/Incident.js | 72 +++++ server/src/db/v1/modules/incidentModule.js | 258 ++++++++++++++++ server/src/service/infrastructure/notes | 83 ++++++ .../service/v1/business/incidentService.js | 277 ++++++++++++++++++ .../v1/infrastructure/statusService.js | 40 ++- 7 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 server/src/db/v1/models/Incident.js create mode 100644 server/src/db/v1/modules/incidentModule.js create mode 100644 server/src/service/infrastructure/notes create mode 100644 server/src/service/v1/business/incidentService.js diff --git a/server/src/config/services.js b/server/src/config/services.js index c1679868d..cecc3bba9 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(); @@ -153,7 +158,17 @@ export const initializeServices = async ({ logger, envSettings, 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 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/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..c37df5bde --- /dev/null +++ b/server/src/db/v1/models/Incident.js @@ -0,0 +1,72 @@ +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: String, + enum: ["active", "resolved"], + default: "active", + 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..8cfa0f0b1 --- /dev/null +++ b/server/src/db/v1/modules/incidentModule.js @@ -0,0 +1,258 @@ +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: "active" }); + } catch (error) { + error.service = SERVICE_NAME; + error.method = "getActiveIncidentByMonitor"; + 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: "resolved", + 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; + } + }; + + getIncidentsByTeam = async ({ teamId, sortOrder, dateRange, filter, page, rowsPerPage, status, monitorId, resolutionType }) => { + try { + page = parseInt(page); + rowsPerPage = parseInt(rowsPerPage); + + const matchStage = { + teamId: new ObjectId(teamId), + ...(status && { status }), + ...(monitorId && { monitorId: new ObjectId(monitorId) }), + ...(resolutionType && { resolutionType }), + ...(dateRangeLookup[dateRange] && { + startTime: { + $gte: dateRangeLookup[dateRange], + }, + }), + }; + + if (filter !== undefined) { + switch (filter) { + case "all": + break; + case "active": + matchStage.status = "active"; + break; + case "resolved": + matchStage.status = "resolved"; + break; + default: + break; + } + } + + sortOrder = sortOrder === "asc" ? 1 : -1; + + let skip = 0; + if (page && rowsPerPage) { + 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 === "active") { + result.active = item.count; + } + if (item._id === "resolved") { + 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/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 ( +