485 lines
13 KiB
JavaScript
485 lines
13 KiB
JavaScript
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;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get incidents by team with filtering
|
|
*
|
|
* STATUS PARAMETER CONTRACT (IMPORTANT - Frontend dependency):
|
|
* This method has an implicit contract with frontend's useFetchIncidents hook:
|
|
*
|
|
* - status === undefined: Frontend uses this to represent "all incidents" (filter === "all")
|
|
* Backend applies $or logic: { status: true } OR { status: false, endTime: >= dateRange }
|
|
* This shows all active incidents + resolved incidents within the date range
|
|
*
|
|
* - status === true: Frontend uses for "active incidents" (filter === "active")
|
|
* Backend shows all active incidents (no date filter applied)
|
|
* Active incidents are currently happening, so date range doesn't apply
|
|
*
|
|
* - status === false: Frontend uses for "resolved incidents" (filter === "resolved")
|
|
* Backend filters by endTime >= dateRange
|
|
* Only shows incidents that were resolved within the specified date range
|
|
*
|
|
* WARNING: Changing this logic will break frontend filtering behavior.
|
|
* The frontend depends on undefined status triggering the $or logic for "all incidents".
|
|
* This contract must be maintained when modifying either frontend or backend code.
|
|
*
|
|
* @param {Object} params - Query parameters
|
|
* @param {string} params.teamId - Team ID
|
|
* @param {string} [params.sortOrder] - Sort order (asc/desc)
|
|
* @param {string} [params.dateRange] - Date range filter
|
|
* @param {number} [params.page] - Page number
|
|
* @param {number} [params.rowsPerPage] - Rows per page
|
|
* @param {boolean|undefined} [params.status] - Status filter. undefined = all, true = active, false = resolved
|
|
* @param {string} [params.monitorId] - Monitor ID filter
|
|
* @param {string} [params.resolutionType] - Resolution type filter
|
|
*/
|
|
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 }),
|
|
};
|
|
|
|
// Date range filter logic (see contract documentation above):
|
|
// - Active incidents (statusBoolean === true): always show (no date filter) - they're currently happening
|
|
// - Resolved incidents (statusBoolean === false): filter by endTime (when they were resolved)
|
|
// - All incidents (statusBoolean === undefined): show all active + resolved in the range using $or
|
|
if (dateRangeLookup[dateRange]) {
|
|
const dateThreshold = dateRangeLookup[dateRange];
|
|
if (statusBoolean === true) {
|
|
// Active incidents: show all active incidents regardless of when they started
|
|
// No date filter applied
|
|
} else if (statusBoolean === false) {
|
|
// Resolved incidents: only show if resolved in the range
|
|
matchStage.endTime = { $gte: dateThreshold };
|
|
} else {
|
|
// All incidents: show all active + resolved incidents in the range
|
|
// This is the critical contract: undefined status triggers $or logic
|
|
matchStage.$or = [
|
|
{ status: true }, // All active incidents
|
|
{ status: false, endTime: { $gte: dateThreshold } }, // Resolved in range
|
|
];
|
|
}
|
|
}
|
|
|
|
sortOrder = sortOrder === "asc" ? 1 : -1;
|
|
|
|
const skip = page * rowsPerPage;
|
|
|
|
const incidents = await this.Incident.aggregate([
|
|
{ $match: matchStage },
|
|
{ $sort: { startTime: sortOrder } },
|
|
{
|
|
$lookup: {
|
|
from: "monitors",
|
|
localField: "monitorId",
|
|
foreignField: "_id",
|
|
as: "monitor",
|
|
},
|
|
},
|
|
{
|
|
$facet: {
|
|
summary: [{ $count: "incidentsCount" }],
|
|
incidents: [{ $skip: skip }, { $limit: rowsPerPage }],
|
|
},
|
|
},
|
|
{
|
|
$project: {
|
|
incidentsCount: {
|
|
$ifNull: [{ $arrayElemAt: ["$summary.incidentsCount", 0] }, 0],
|
|
},
|
|
incidents: {
|
|
$map: {
|
|
input: { $ifNull: ["$incidents", []] },
|
|
as: "incident",
|
|
in: {
|
|
$mergeObjects: [
|
|
"$$incident",
|
|
{
|
|
monitorName: {
|
|
$let: {
|
|
vars: {
|
|
monitor: { $arrayElemAt: ["$$incident.monitor", 0] },
|
|
},
|
|
in: {
|
|
$ifNull: ["$$monitor.name", null],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
|
|
return incidents[0];
|
|
} catch (error) {
|
|
error.service = SERVICE_NAME;
|
|
error.method = "getIncidentsByTeam";
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
getIncidentSummary = async ({ teamId, limit = 10 }) => {
|
|
try {
|
|
const matchStage = {
|
|
teamId: new ObjectId(teamId),
|
|
};
|
|
|
|
// Get basic counts and resolution types
|
|
const countsPipeline = [
|
|
{ $match: matchStage },
|
|
{
|
|
$group: {
|
|
_id: "$status",
|
|
count: { $sum: 1 },
|
|
manualResolutions: {
|
|
$sum: {
|
|
$cond: [{ $eq: ["$resolutionType", "manual"] }, 1, 0],
|
|
},
|
|
},
|
|
automaticResolutions: {
|
|
$sum: {
|
|
$cond: [{ $eq: ["$resolutionType", "automatic"] }, 1, 0],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
const counts = await this.Incident.aggregate(countsPipeline);
|
|
|
|
// Calculate totals
|
|
let total = 0;
|
|
let active = 0;
|
|
let resolved = 0;
|
|
let manual = 0;
|
|
let automatic = 0;
|
|
|
|
counts.forEach((item) => {
|
|
total += item.count;
|
|
if (item._id === true) {
|
|
active = item.count;
|
|
}
|
|
if (item._id === false) {
|
|
resolved = item.count;
|
|
}
|
|
manual += item.manualResolutions;
|
|
automatic += item.automaticResolutions;
|
|
});
|
|
|
|
// Calculate average resolution time (in milliseconds)
|
|
const resolutionTimePipeline = [
|
|
{ $match: { ...matchStage, status: false, endTime: { $exists: true, $ne: null } } },
|
|
{
|
|
$project: {
|
|
resolutionTime: {
|
|
$subtract: ["$endTime", "$startTime"],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
$group: {
|
|
_id: null,
|
|
avgResolutionTime: { $avg: "$resolutionTime" },
|
|
},
|
|
},
|
|
];
|
|
|
|
const resolutionTimeResult = await this.Incident.aggregate(resolutionTimePipeline);
|
|
const avgResolutionTimeMs = resolutionTimeResult[0]?.avgResolutionTime || 0;
|
|
const avgResolutionTimeHours = avgResolutionTimeMs / (1000 * 60 * 60); // Convert to hours
|
|
|
|
// Get monitor with most incidents
|
|
const monitorPipeline = [
|
|
{ $match: matchStage },
|
|
{
|
|
$group: {
|
|
_id: "$monitorId",
|
|
count: { $sum: 1 },
|
|
},
|
|
},
|
|
{ $sort: { count: -1 } },
|
|
{ $limit: 1 },
|
|
{
|
|
$lookup: {
|
|
from: "monitors",
|
|
localField: "_id",
|
|
foreignField: "_id",
|
|
as: "monitor",
|
|
},
|
|
},
|
|
{
|
|
$project: {
|
|
monitorId: "$_id",
|
|
count: 1,
|
|
monitorName: { $arrayElemAt: ["$monitor.name", 0] },
|
|
},
|
|
},
|
|
];
|
|
|
|
const monitorResult = await this.Incident.aggregate(monitorPipeline);
|
|
const topMonitor = monitorResult[0] || null;
|
|
|
|
// Get latest incidents
|
|
const latestIncidentsPipeline = [
|
|
{ $match: matchStage },
|
|
{ $sort: { createdAt: -1 } },
|
|
{ $limit: Math.max(1, parseInt(limit) || 10) },
|
|
{
|
|
$lookup: {
|
|
from: "monitors",
|
|
localField: "monitorId",
|
|
foreignField: "_id",
|
|
as: "monitor",
|
|
},
|
|
},
|
|
{
|
|
$project: {
|
|
_id: 1,
|
|
monitorId: 1,
|
|
monitorName: { $arrayElemAt: ["$monitor.name", 0] },
|
|
status: 1,
|
|
startTime: 1,
|
|
endTime: 1,
|
|
resolutionType: 1,
|
|
message: 1,
|
|
statusCode: 1,
|
|
createdAt: 1,
|
|
},
|
|
},
|
|
];
|
|
|
|
const latestIncidents = await this.Incident.aggregate(latestIncidentsPipeline);
|
|
|
|
return {
|
|
totalActive: active,
|
|
avgResolutionTimeHours: Math.round(avgResolutionTimeHours * 100) / 100, // Round to 2 decimal places
|
|
topMonitor: topMonitor
|
|
? {
|
|
monitorId: topMonitor.monitorId,
|
|
monitorName: topMonitor.monitorName,
|
|
incidentCount: topMonitor.count,
|
|
}
|
|
: null,
|
|
total: total,
|
|
totalManualResolutions: manual,
|
|
totalAutomaticResolutions: automatic,
|
|
latestIncidents: latestIncidents,
|
|
};
|
|
} 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;
|