diff --git a/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx b/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx index f198c5c18..d68b0b85e 100644 --- a/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx +++ b/client/src/Pages/Incidents/Components/IncidentDetailsModal/index.jsx @@ -21,12 +21,12 @@ const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { const theme = useTheme(); const { t } = useTranslation(); const { fetchIncidentById, resolveIncident } = useFetchIncidents(); - const [incident, setIncident] = useState(null); + const [incidentData, setIncidentData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isResolveDialogOpen, setIsResolveDialogOpen] = useState(false); const uiTimezone = useSelector((state) => state.ui.timezone); - const isActive = incident?.status === true; - const duration = useGetIncidentsDuration(incident || null, isActive); + const isActive = incidentData?.incident?.status === true; + const duration = useGetIncidentsDuration(incidentData?.incident || null, isActive); const statusColor = isActive ? theme.palette.error.main : theme.palette.success.main; const toCapitalLetter = (text) => @@ -75,7 +75,7 @@ const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { useEffect(() => { const loadIncident = async () => { if (!incidentId || !open) { - setIncident(null); + setIncidentData(null); setIsLoading(false); return; } @@ -83,10 +83,10 @@ const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { setIsLoading(true); try { const incidentData = await fetchIncidentById(incidentId); - setIncident(incidentData); + setIncidentData(incidentData); } catch (error) { console.error("Error fetching incident:", error); - setIncident(null); + setIncidentData(null); } finally { setIsLoading(false); } @@ -107,11 +107,13 @@ const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { if (!incidentId) return; const updatedIncident = await fetchIncidentById(incidentId); if (updatedIncident) { - setIncident(updatedIncident); + setIncidentData(updatedIncident); } }; const renderContent = () => { + console.log(incidentData); + if (isLoading) { return ( { ); } - if (!incident) { + if (!incidentData) { return ( { variant="body1" sx={{ flex: 1 }} > - {incident?.monitorId?.name || t("incidentsPage.unknownMonitor")} + {incidentData?.monitor?.name || t("incidentsPage.unknownMonitor")} { variant="body1" sx={{ flex: 1, wordBreak: "break-word", fontFamily: "monospace" }} > - {incident?.monitorId?.url || "-"} + {incidentData?.monitor?.url || "-"} @@ -247,7 +249,7 @@ const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { label={t("incidentsPage.startedAt")} value={ formatDateWithTz( - incident?.startTime, + incidentData?.incident?.startTime, "D MMM YYYY, h:mm A", uiTimezone ) || "-" @@ -259,7 +261,7 @@ const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { label={t("incidentsPage.endedAt")} value={ formatDateWithTz( - incident?.endTime, + incidentData?.incident?.endTime, "D MMM YYYY, h:mm A", uiTimezone ) || "-" @@ -303,26 +305,28 @@ const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { {!isActive && ( <> - {incident?.resolutionType === "manual" && ( + {incidentData?.incident?.resolutionType === "manual" && ( @@ -353,15 +357,15 @@ const IncidentDetailsModal = ({ open, incidentId, onClose, onResolved }) => { > {renderContent()} {!isActive && - incident?.resolutionType === "manual" && - incident?.resolvedBy?.email && ( + incidentData?.incident?.resolutionType === "manual" && + incidentData?.user?.email && ( - {t("incidentsPage.resolvedBy")}: {incident.resolvedBy.email} + {t("incidentsPage.resolvedBy")}: {incidentData.user.email} )} => { // Create DB - const incidentModule = new IncidentModule({ logger, Incident, Monitor, User }); const db = new MongoDB({ logger, envSettings, - incidentModule, }); await db.connect(); @@ -175,10 +168,11 @@ export const initializeServices = async ({ const errorService = new ErrorService(); const incidentService = new IncidentService({ - db, logger, errorService, incidentsRepository, + monitorsRepository, + usersRepository, }); const checkService = new CheckService({ diff --git a/server/src/controllers/incidentController.ts b/server/src/controllers/incidentController.ts index 6231f3cf4..72e6498f9 100644 --- a/server/src/controllers/incidentController.ts +++ b/server/src/controllers/incidentController.ts @@ -66,10 +66,13 @@ class IncidentController { getIncidentById = async (req: Request, res: Response, next: NextFunction) => { try { - const incident = await this.incidentService.getIncidentById({ - incidentId: req?.params?.incidentId, - teamId: req?.user?.teamId, - }); + const teamId = requireTeamId(req.user?.teamId); + const incidentId = req.params.incidentId; + if (!incidentId) { + throw new AppError({ message: "Incident ID is required", service: SERVICE_NAME, status: 400 }); + } + + const incident = await this.incidentService.getIncidentById(incidentId, teamId); return res.status(200).json({ success: true, diff --git a/server/src/db/MongoDB.js b/server/src/db/MongoDB.js index f149fbd6d..97498f13a 100755 --- a/server/src/db/MongoDB.js +++ b/server/src/db/MongoDB.js @@ -4,10 +4,9 @@ import { runMigrations } from "./migration/index.js"; class MongoDB { static SERVICE_NAME = "MongoDB"; - constructor({ logger, envSettings, incidentModule }) { + constructor({ logger, envSettings }) { this.logger = logger; this.envSettings = envSettings; - this.incidentModule = incidentModule; } get serviceName() { diff --git a/server/src/db/modules/incidentModule.js b/server/src/db/modules/incidentModule.js deleted file mode 100644 index 366e309f6..000000000 --- a/server/src/db/modules/incidentModule.js +++ /dev/null @@ -1,484 +0,0 @@ -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; diff --git a/server/src/repositories/incidents/IIncidentsRepository.ts b/server/src/repositories/incidents/IIncidentsRepository.ts index 7c2b0179e..3c136bf43 100644 --- a/server/src/repositories/incidents/IIncidentsRepository.ts +++ b/server/src/repositories/incidents/IIncidentsRepository.ts @@ -4,6 +4,7 @@ export interface IIncidentsRepository { // create create(incident: Partial): Promise; // fetch + findById(incidentId: string, teamId: string): Promise; findActiveByIncidentId(incidentId: string, teamId: string): Promise; findActiveByMonitorId(monitorId: string, teamId: string): Promise; findByTeamId( diff --git a/server/src/repositories/incidents/MongoIncidentRepository.ts b/server/src/repositories/incidents/MongoIncidentRepository.ts index 10cb47213..b029953ca 100644 --- a/server/src/repositories/incidents/MongoIncidentRepository.ts +++ b/server/src/repositories/incidents/MongoIncidentRepository.ts @@ -79,6 +79,17 @@ class MongoIncidentRepository implements IIncidentsRepository { return this.toEntity(newIncident); } + findById = async (incidentId: string, teamId: string): Promise => { + const incident = await IncidentModel.findOne({ + _id: new mongoose.Types.ObjectId(incidentId), + teamId: new mongoose.Types.ObjectId(teamId), + }); + if (!incident) { + throw new AppError({ message: `Incident with id ${incidentId} not found`, status: 404 }); + } + return this.toEntity(incident); + }; + findActiveByIncidentId = async (incidentId: string, teamId: string): Promise => { const incident = await IncidentModel.findOne({ _id: new mongoose.Types.ObjectId(incidentId), diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index e3fb4cfc3..37a02e30d 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -2,7 +2,7 @@ const SERVICE_NAME = "incidentService"; import type { Monitor } from "@/types/monitor.js"; import { AppError } from "@/utils/AppError.js"; import { ParseBoolean } from "@/utils/utils.js"; -import type { IIncidentsRepository } from "@/repositories/index.js"; +import type { IIncidentsRepository, IMonitorsRepository, IUsersRepository } from "@/repositories/index.js"; import type { Incident } from "@/types/index.js"; const dateRangeLookup: Record = { @@ -17,27 +17,30 @@ const dateRangeLookup: Record = { class IncidentService { static SERVICE_NAME = SERVICE_NAME; - private db: any; private logger: any; private errorService: any; - private: any; private incidentsRepository: IIncidentsRepository; + private monitorsRepository: IMonitorsRepository; + private usersRepository: IUsersRepository; constructor({ - db, logger, errorService, incidentsRepository, + monitorsRepository, + usersRepository, }: { - db: any; logger: any; errorService: any; incidentsRepository: IIncidentsRepository; + monitorsRepository: IMonitorsRepository; + usersRepository: IUsersRepository; }) { - this.db = db; this.logger = logger; this.errorService = errorService; this.incidentsRepository = incidentsRepository; + this.monitorsRepository = monitorsRepository; + this.usersRepository = usersRepository; } get serviceName() { @@ -193,27 +196,15 @@ class IncidentService { } }; - getIncidentById = async ({ incidentId, teamId }: { incidentId: string; teamId: string }) => { + getIncidentById = async (incidentId: string, teamId: string) => { try { - if (!incidentId) { - throw this.errorService.createBadRequestError("No incident ID in request"); + const incident = await this.incidentsRepository.findById(incidentId, teamId); + const monitor = await this.monitorsRepository.findById(incident.monitorId, teamId); + let user = null; + if (incident.resolvedBy) { + user = await this.usersRepository.findById(incident.resolvedBy); } - - 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; + return { incident, monitor, user }; } catch (error: any) { this.logger.error({ service: SERVICE_NAME,