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
This commit is contained in:
karenvicent
2025-11-06 17:06:33 -05:00
parent eb70c9f5f0
commit 0c3fdbc539
7 changed files with 748 additions and 4 deletions
+17 -3
View File
@@ -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
+2
View File
@@ -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() {
+72
View File
@@ -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;
+258
View File
@@ -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;
+83
View File
@@ -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<SelectProps> = ({ ...props }) => {
const theme = useTheme();
return (
<Select
{...props}
sx={{
height: "34px",
"& .MuiOutlinedInput-notchedOutline": {
borderRadius: theme.shape.borderRadius,
borderColor: theme.palette.primary.lowContrast,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderColor: theme.palette.primary.lowContrast,
},
}}
/>
);
};
type ItemTypes = string | number;
interface SelectItem {
_id: ItemTypes;
name: string;
}
export type CustomSelectProps = SelectProps & {
items: SelectItem[];
placeholder?: string;
isHidden?: boolean;
hasError?: boolean;
};
export const SelectFromItems: React.FC<CustomSelectProps> = (
{ items, placeholder, isHidden = false, hasError = false, ...props }
) => {
return (
<SelectInput
error={hasError}
IconComponent={KeyboardArrowDownIcon}
displayEmpty
MenuProps={{ disableScrollLock: true }}
renderValue={(selected) => {
if (!selected) {
return (
<Typography
noWrap
color="text.secondary"
>
{placeholder ?? ""}
</Typography>
);
}
const selectedItem = items.find((item) => item._id === selected);
const displayName = selectedItem ? selectedItem.name : placeholder;
return (
<Typography
noWrap
title={displayName}
>
{displayName}
</Typography>
);
}}
{...props}
>
{items.map((item) => (
<MenuItem
key={item._id}
value={item._id}
>
{item.name}
</MenuItem>
))}
</SelectInput>
);
}
SelectInput.displayName = "SelectInput";
@@ -0,0 +1,277 @@
const SERVICE_NAME = "incidentService";
class IncidentService {
static SERVICE_NAME = SERVICE_NAME;
constructor({ db, logger, errorService, stringService }) {
this.db = db;
this.logger = logger;
this.errorService = errorService;
this.stringService = stringService;
}
get serviceName() {
return IncidentService.SERVICE_NAME;
}
createIncident = async (monitor, check) => {
try {
if (!monitor || !monitor._id) {
throw this.errorService.createBadRequestError("Monitor is required");
}
if (!check || !check._id) {
throw this.errorService.createBadRequestError("Check is required");
}
const activeIncident = await this.db.incidentModule.getActiveIncidentByMonitor(monitor._id);
if (activeIncident) {
await this.db.incidentModule.addCheckToIncident(activeIncident._id, check._id);
this.logger.info({
service: this.SERVICE_NAME,
method: "createIncident",
message: `Check added to existing active incident for monitor ${monitor.name}`,
incidentId: activeIncident._id,
monitorId: monitor._id,
});
return activeIncident;
}
const incidentData = {
monitorId: monitor._id,
teamId: monitor.teamId,
type: monitor.type,
startTime: new Date(),
status: "active",
message: check.message || null,
statusCode: check.statusCode || null,
checks: [check._id],
};
const incident = await this.db.incidentModule.createIncident(incidentData);
this.logger.info({
service: this.SERVICE_NAME,
method: "createIncident",
message: `New incident created for monitor ${monitor.name}`,
incidentId: incident._id,
monitorId: monitor._id,
});
return incident;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "createIncident",
message: error.message,
monitorId: monitor?._id,
error: error.stack,
});
throw error;
}
};
resolveIncident = async (monitor, check) => {
try {
if (!monitor || !monitor._id) {
throw this.errorService.createBadRequestError("Monitor is required");
}
const activeIncident = await this.db.incidentModule.getActiveIncidentByMonitor(monitor._id);
if (!activeIncident) {
this.logger.info({
service: this.SERVICE_NAME,
method: "resolveIncident",
message: `No active incident found for monitor ${monitor.name}`,
monitorId: monitor._id,
});
return null;
}
await this.db.incidentModule.addCheckToIncident(activeIncident._id, check._id);
const resolvedIncident = await this.db.incidentModule.resolveIncident(activeIncident._id, {
resolutionType: "automatic",
resolvedBy: null,
endTime: new Date(),
});
this.logger.info({
service: this.SERVICE_NAME,
method: "resolveIncident",
message: `Incident automatically resolved for monitor ${monitor.name}`,
incidentId: resolvedIncident._id,
monitorId: monitor._id,
});
return resolvedIncident;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "resolveIncident",
message: error.message,
monitorId: monitor?._id,
error: error.stack,
});
throw error;
}
};
resolveIncidentManually = async ({ incidentId, userId, teamId, comment }) => {
try {
if (!incidentId) {
throw this.errorService.createBadRequestError("No incident ID in request");
}
if (!userId) {
throw this.errorService.createBadRequestError("No user ID in request");
}
if (!teamId) {
throw this.errorService.createBadRequestError("No team ID in request");
}
const incident = await this.db.incidentModule.getIncidentById(incidentId);
if (!incident) {
throw this.errorService.createNotFoundError("Incident not found");
}
if (!incident.teamId.equals(teamId)) {
throw this.errorService.createAuthorizationError();
}
if (incident.status === "resolved") {
throw this.errorService.createBadRequestError("Incident is already resolved");
}
const resolvedIncident = await this.db.incidentModule.resolveIncident(incidentId, {
resolutionType: "manual",
resolvedBy: userId,
comment: comment || null,
endTime: new Date(),
});
this.logger.info({
service: this.SERVICE_NAME,
method: "resolveIncidentManually",
message: `Incident manually resolved by user`,
incidentId: resolvedIncident._id,
userId,
});
return resolvedIncident;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "resolveIncidentManually",
message: error.message,
incidentId,
error: error.stack,
});
throw error;
}
};
getIncidentsByTeam = async ({ teamId, query }) => {
try {
if (!teamId) {
throw this.errorService.createBadRequestError("No team ID in request");
}
const { sortOrder, dateRange, filter, page, rowsPerPage, status, monitorId, resolutionType } = query || {};
const result = await this.db.incidentModule.getIncidentsByTeam({
teamId,
sortOrder,
dateRange,
filter,
page,
rowsPerPage,
status,
monitorId,
resolutionType,
});
return result;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "getIncidentsByTeam",
message: error.message,
teamId,
error: error.stack,
});
throw error;
}
};
getIncidentSummary = async ({ teamId, query }) => {
try {
if (!teamId) {
throw this.errorService.createBadRequestError("No team ID in request");
}
const { dateRange } = query || {};
const summary = await this.db.incidentModule.getIncidentSummary({
teamId,
dateRange,
});
return summary;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "getIncidentSummary",
message: error.message,
teamId,
error: error.stack,
});
throw error;
}
};
getIncidentById = async ({ incidentId, teamId }) => {
try {
if (!incidentId) {
throw this.errorService.createBadRequestError("No incident ID in request");
}
if (!teamId) {
throw this.errorService.createBadRequestError("No team ID in request");
}
const incident = await this.db.incidentModule.getIncidentById(incidentId);
if (!incident) {
throw this.errorService.createNotFoundError("Incident not found");
}
if (!incident.teamId.equals(teamId)) {
throw this.errorService.createAuthorizationError();
}
return incident;
} catch (error) {
this.logger.error({
service: this.SERVICE_NAME,
method: "getIncidentById",
message: error.message,
incidentId,
error: error.stack,
});
throw error;
}
};
}
export default IncidentService;
@@ -1,4 +1,5 @@
import MonitorStats from "../../../db/v1/models/MonitorStats.js";
import Check from "../../../db/v1/models/Check.js";
const SERVICE_NAME = "StatusService";
class StatusService {
@@ -7,11 +8,13 @@ class StatusService {
/**
* @param {{
* buffer: import("./bufferService.js").BufferService
* incidentService: import("../business/incidentService.js").IncidentService
* }}
*/ constructor({ db, logger, buffer }) {
*/ constructor({ db, logger, buffer, incidentService }) {
this.db = db;
this.logger = logger;
this.buffer = buffer;
this.incidentService = incidentService;
}
get serviceName() {
@@ -170,6 +173,41 @@ class StatusService {
prevStatus,
newStatus,
});
let savedCheck = check;
if (!check._id) {
try {
const checkModel = new Check(check);
savedCheck = await checkModel.save();
} catch (checkError) {
this.logger.error({
service: this.SERVICE_NAME,
method: "updateStatus",
message: `Failed to save check immediately: ${checkError.message}`,
monitorId: monitor._id,
stack: checkError.stack,
});
}
}
try {
if (newStatus === false) {
await this.incidentService.createIncident(monitor, savedCheck);
} else if (prevStatus === false) {
await this.incidentService.resolveIncident(monitor, savedCheck);
}
} catch (incidentError) {
this.logger.error({
service: this.SERVICE_NAME,
method: "updateStatus",
message: `Failed to handle incident: ${incidentError.message}`,
monitorId: monitor._id,
statusChanged,
prevStatus,
newStatus,
stack: incidentError.stack,
});
}
}
monitor.status = newStatus;