From 0c9a0cbcabe052954ecf0de82a90dd542945d0a6 Mon Sep 17 00:00:00 2001 From: justins Date: Fri, 10 Apr 2026 22:52:28 -0400 Subject: [PATCH 1/2] complete --- ESCALATION_IMPLEMENTATION_PLAN.md | 114 +++++++++ client/src/Hooks/useMonitorForm.ts | 1 + client/src/Pages/CreateMonitor/index.tsx | 234 ++++++++++++------ client/src/Types/Monitor.ts | 9 + client/src/Validation/monitor.ts | 9 + server/jest.config.cjs | 9 + server/jest.config.ts | 42 ++-- server/package-lock.json | 34 +-- server/package.json | 7 +- server/src/config/services.ts | 34 +-- server/src/db/models/Monitor.ts | 10 + .../monitors/MongoMonitorsRepository.ts | 1 + .../src/service/business/incidentService.ts | 17 +- .../infrastructure/notificationsService.ts | 33 ++- server/src/types/monitor.ts | 9 + server/src/validation/monitorValidation.ts | 18 ++ server/test/escalationRules.test.ts | 37 +++ server/test/migrate-escalationRules.js | 18 ++ 18 files changed, 502 insertions(+), 134 deletions(-) create mode 100644 ESCALATION_IMPLEMENTATION_PLAN.md create mode 100644 server/jest.config.cjs create mode 100644 server/test/escalationRules.test.ts create mode 100644 server/test/migrate-escalationRules.js diff --git a/ESCALATION_IMPLEMENTATION_PLAN.md b/ESCALATION_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000000..35989e73d0 --- /dev/null +++ b/ESCALATION_IMPLEMENTATION_PLAN.md @@ -0,0 +1,114 @@ +# Escalated Notifications Implementation Plan + +## Overview +This document outlines the implementation plan for adding escalated notifications to monitors in the Checkmate project. The goal is to allow users to define escalation rules for each monitor, persist these rules in the backend, edit them in the frontend, and integrate escalation logic into the notification and incident lifecycle. + +--- + +## 1. Data Model Changes + +### Backend +- **File:** `server/src/types/monitor.ts` + - Add an `escalationRules` field to the `Monitor` interface. Example: + ```ts + escalationRules?: Array<{ + delayMinutes: number; // Minutes after initial alert to escalate + notificationChannelIds: string[]; // IDs of notification channels to use + messageTemplate?: string; // Optional custom message + }>; + ``` +- **File:** `server/src/db/models/Monitor.ts` + - Update the Mongoose schema to include `escalationRules` as an array of subdocuments. +- **File:** `server/src/validation/monitorValidation.ts` + - Update Zod schemas for monitor create/edit to validate the new `escalationRules` field. + +### Frontend +- **File:** `client/src/Types/Monitor.ts` + - Add `escalationRules` to the `Monitor` type. +- **File:** `client/src/Validation/monitor.ts` + - Update Zod schemas for monitor forms to support `escalationRules`. + +--- + +## 2. API Changes +- Ensure monitor create/update endpoints accept and persist `escalationRules`. +- Update OpenAPI spec (`server/openapi.json`) if present. + +--- + +## 3. UI/UX Changes + +### Monitor Create/Edit +- **File:** `client/src/Pages/CreateMonitor/index.tsx` + - Add UI section for defining escalation rules: + - Allow adding multiple escalation steps (delay, channels, message). + - Use notification channels from `/notifications/team`. + - Validate and submit escalation rules as part of monitor form. +- **File:** `client/src/Hooks/useMonitorForm.ts` + - Add logic to handle escalation rules in form state and defaults. + +### Monitor Details (Optional) +- Display escalation rules in monitor details view for transparency. + +--- + +## 4. Notification & Incident Logic + +### Backend +- **File:** `server/src/service/business/incidentService.ts` + - On incident creation, schedule escalation notifications based on rules. + - Track escalation state (e.g., which steps have been sent) in incident or a new collection. +- **File:** `server/src/service/infrastructure/notificationsService.ts` + - Add logic to send escalation notifications at the correct time. + - Ensure deduplication and correct channel targeting. + +### Data Persistence +- Consider tracking escalation progress in the incident document or a new escalation-tracking collection. + +--- + +## 5. Testing & Validation +- Update/create tests for monitor creation, update, and escalation logic. +- Test UI for adding, editing, and displaying escalation rules. +- Test notification dispatch and escalation timing. + +--- + +## 6. Migration +- Write a migration script if needed to add `escalationRules` to existing monitors (default: empty array). + +--- + +## 7. Affected Files Summary +- `server/src/types/monitor.ts` +- `server/src/db/models/Monitor.ts` +- `server/src/validation/monitorValidation.ts` +- `server/src/service/business/incidentService.ts` +- `server/src/service/infrastructure/notificationsService.ts` +- `client/src/Types/Monitor.ts` +- `client/src/Validation/monitor.ts` +- `client/src/Pages/CreateMonitor/index.tsx` +- `client/src/Hooks/useMonitorForm.ts` +- (Optional) `client/src/Pages/MonitorDetails/` +- (Optional) Migration script location + +--- + +## 8. Open Questions +- Where to persist escalation state: incident doc or new collection? +- Should escalations be visible in incident history UI? +- Should escalations support custom messages per step? + +--- + +## 9. Next Steps +1. Confirm data model and API changes. +2. Implement backend model/schema/validation updates. +3. Implement frontend type/schema/form updates. +4. Add UI for escalation rules in monitor create/edit. +5. Integrate escalation logic into incident/notification services. +6. Test end-to-end. + +--- + +*This plan is based on the current Checkmate repo structure and code inspection as of this writing.* diff --git a/client/src/Hooks/useMonitorForm.ts b/client/src/Hooks/useMonitorForm.ts index 963409fc8a..fcb3ee10b0 100644 --- a/client/src/Hooks/useMonitorForm.ts +++ b/client/src/Hooks/useMonitorForm.ts @@ -17,6 +17,7 @@ const getBaseDefaults = (data?: Monitor | null) => ({ geoCheckEnabled: data?.geoCheckEnabled ?? false, geoCheckLocations: data?.geoCheckLocations || [], geoCheckInterval: data?.geoCheckInterval || 300000, + escalationRules: data?.escalationRules || [], }); export const useMonitorForm = ({ diff --git a/client/src/Pages/CreateMonitor/index.tsx b/client/src/Pages/CreateMonitor/index.tsx index 15b76eab36..a8d846954f 100644 --- a/client/src/Pages/CreateMonitor/index.tsx +++ b/client/src/Pages/CreateMonitor/index.tsx @@ -1,8 +1,7 @@ -import { useMemo, useState } from "react"; -import { useEffect } from "react"; +import { useMemo, useState, useEffect } from "react"; +import { useForm, Controller, useFieldArray } from "react-hook-form"; import { logger } from "@/Utils/logger"; import { useParams, useLocation, useNavigate } from "react-router"; -import { useForm, Controller } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTheme } from "@mui/material"; import Stack from "@mui/material/Stack"; @@ -30,7 +29,7 @@ import { Dialog, } from "@/Components/inputs"; import { SPACING, LAYOUT } from "@/Utils/Theme/constants"; -import { useGet, usePost, usePatch, useDelete } from "@/Hooks/UseApi"; +import { useGet, useDelete, usePost, usePatch } from "@/Hooks/UseApi"; import { useMonitorForm } from "@/Hooks/useMonitorForm"; import { type Monitor, @@ -209,7 +208,6 @@ const CreateMonitorPage = () => { }, [defaults, form]); const watchedType = watch("type") as MonitorType; - const watchedUseAdvancedMatching = watch("useAdvancedMatching") as boolean; const watchGeoCheckEnabled = watch("geoCheckEnabled") as boolean; @@ -222,9 +220,88 @@ const CreateMonitorPage = () => { [watchedType, t] ); - const { post, loading: isCreating } = usePost(); - const { patch, loading: isUpdating } = usePatch(); - const isSubmitting = isCreating || isUpdating; + // Escalation Rules Field Array and notification options (must be after control and notifications) + const { fields: escalationFields, append: appendEscalation, remove: removeEscalation } = useFieldArray({ + control, + name: "escalationRules" + }); + const notificationOptions = (notifications ?? []).map((n: Notification) => ({ + ...n, + name: n.notificationName, + })); + + // Escalation Rules UI + const renderEscalationRules = () => ( + + {escalationFields.map((field, idx) => ( + + ( + field.onChange(Number(e.target.value))} + /> + )} + /> + { + // Ensure all selected IDs are represented in the value, even if not in options + const selectedOptions = (field.value ?? []).map((id: string) => + notificationOptions.find((n) => n.id === id) || { id, name: id } + ); + return ( + option.name} + onChange={(_, newValue: Notification[]) => field.onChange(newValue.map((n: Notification) => n.id))} + isOptionEqualToValue={(option, value) => option.id === value.id} + fieldLabel={t("Notification Channels")} + sx={{ minWidth: 220 }} + /> + ); + }} + /> + ( + + )} + /> + removeEscalation(idx)} aria-label="Remove escalation step"> + + + + ))} + + + } + /> + ); + // Delete functionality const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const { deleteFn, loading: isDeleting } = useDelete(); @@ -251,7 +328,18 @@ const CreateMonitorPage = () => { setIsDeleteDialogOpen(false); }; + const { post, loading: isCreating } = usePost(); + const { patch, loading: isUpdating } = usePatch(); + const isSubmitting = isCreating || isUpdating; + const onSubmit = async (data: MonitorFormData) => { + // Debug: log escalationRules and notificationChannelIds + console.log('Submitting monitor form data:', data); + if (data.escalationRules) { + data.escalationRules.forEach((rule, idx) => { + console.log(`Escalation Rule #${idx}:`, rule); + }); + } let result; if (isEditMode && monitorId) { result = await patch(`/monitors/${monitorId}`, data); @@ -697,73 +785,69 @@ const CreateMonitorPage = () => { } /> - { - // Map notifications to have 'name' property for Autocomplete - const notificationOptions = (notifications ?? []).map((n) => ({ - ...n, - name: n.notificationName, - })); - const selectedNotifications = notificationOptions.filter((n) => - (field.value ?? []).includes(n.id) - ); - return ( - - option.name} - onChange={(_: unknown, newValue: typeof notificationOptions) => { - field.onChange(newValue.map((n) => n.id)); - }} - isOptionEqualToValue={(option, value) => option.id === value.id} - /> - {selectedNotifications.length > 0 && ( - - {selectedNotifications.map((notification, index) => ( - - - {notification.notificationName} - - { - field.onChange( - (field.value ?? []).filter( - (id: string) => id !== notification.id - ) - ); - }} - aria-label="Remove notification" + { + const selectedNotifications = notificationOptions.filter((n) => + (field.value ?? []).includes(n.id) + ); + return ( + + option.name} + onChange={(_: unknown, newValue: Notification[]) => field.onChange(newValue.map((n) => n.id))} + isOptionEqualToValue={(option, value) => option.id === value.id} + /> + {selectedNotifications.length > 0 && ( + + {selectedNotifications.map((notification, index) => ( + - - - {index < selectedNotifications.length - 1 && } - - ))} - - )} - - ); - }} - /> - } - /> + + {notification.notificationName} + + { + field.onChange( + (field.value ?? []).filter( + (id: string) => id !== notification.id + ) + ); + }} + aria-label="Remove notification" + > + + + {index < selectedNotifications.length - 1 && } + + ))} + + )} + + ); + }} + /> + } + /> + + {/* Escalation Rules Section */} + {renderEscalationRules()} {(watchedType === "http" || watchedType === "grpc" || @@ -871,7 +955,7 @@ const CreateMonitorPage = () => { /> ( ; } export type MonitorWithChecks = Monitor; diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts index 9acffe6fed..7fca0c79c1 100644 --- a/client/src/Validation/monitor.ts +++ b/client/src/Validation/monitor.ts @@ -27,6 +27,15 @@ const baseSchema = z.object({ .number() .min(300000, "Interval must be at least 5 minutes") .optional(), + escalationRules: z + .array( + z.object({ + delayMinutes: z.number().min(1, "Delay must be at least 1 minute"), + notificationChannelIds: z.array(z.string().min(1, "Channel is required")), + messageTemplate: z.string().optional(), + }) + ) + .optional(), }); // HTTP monitor schema diff --git a/server/jest.config.cjs b/server/jest.config.cjs new file mode 100644 index 0000000000..263640a3a4 --- /dev/null +++ b/server/jest.config.cjs @@ -0,0 +1,9 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ["**/test/**/*.test.ts"], + transform: { + '^.+\\.ts$': 'ts-jest', + }, + moduleFileExtensions: ['ts', 'js', 'json', 'node'], +}; diff --git a/server/jest.config.ts b/server/jest.config.ts index fd0c87ad7b..2c9116cc68 100644 --- a/server/jest.config.ts +++ b/server/jest.config.ts @@ -1,25 +1,27 @@ -import type { Config } from "jest"; +import type { Config } from 'jest'; const config: Config = { - rootDir: ".", - testEnvironment: "node", - extensionsToTreatAsEsm: [".ts"], - transform: { - "^.+\\.(t|j)sx?$": ["ts-jest", { useESM: true, tsconfig: "./tsconfig.jest.json" }], - }, - moduleNameMapper: { - "^@/validation/(.*)\\.js$": "/src/validation/$1.js", - "^@/utils/(AppError)\\.js$": "/src/utils/$1.ts", - "^@/utils/(.*)\\.js$": "/src/utils/$1.js", - "^@/(.*)\\.ts$": "/src/$1.ts", - "^@/(.*)\\.js$": "/src/$1.ts", - "^@/(.*)$": "/src/$1", - }, - testMatch: ["/test/**/*.test.ts"], - setupFilesAfterEnv: [], - collectCoverageFrom: ["src/**/*.ts"], - coveragePathIgnorePatterns: ["/node_modules/", "/test/"], - clearMocks: true, + preset: 'ts-jest/presets/default-esm', + rootDir: '.', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts', '.mts'], + transform: { + '^.+\\.ts$': ['ts-jest', { useESM: true, tsconfig: './tsconfig.jest.json' }], + }, + moduleNameMapper: { + '^@/validation/(.*)\\.js$': '/src/validation/$1.ts', + '^@/utils/(AppError)\\.js$': '/src/utils/$1.ts', + '^@/utils/(.*)\\.js$': '/src/utils/$1.ts', + '^@/(.*)\\.ts$': '/src/$1.ts', + '^@/(.*)\\.js$': '/src/$1.ts', + '^@/(.*)$': '/src/$1.ts', + }, + moduleFileExtensions: ['mts', 'ts', 'js', 'json', 'node'], + testMatch: ['/test/**/*.test.ts', '/test/**/*.test.mts'], + setupFilesAfterEnv: [], + collectCoverageFrom: ['src/**/*.ts'], + coveragePathIgnorePatterns: ['/node_modules/', '/test/'], + clearMocks: true, }; export default config; diff --git a/server/package-lock.json b/server/package-lock.json index 5d9d920ee5..a2c95835b3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -34,7 +34,7 @@ "jsonwebtoken": "9.0.2", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", - "mongoose": "^8.3.3", + "mongoose": "^8.23.0", "multer": "^1.4.5-lts.1", "nodemailer": "8.0.1", "ping": "0.4.4", @@ -76,7 +76,7 @@ "lint-staged": "^16.2.7", "nodemon": "^3.1.11", "prettier": "^3.3.3", - "ts-jest": "^29.4.6", + "ts-jest": "^29.4.9", "ts-node": "^10.9.2", "tsc-alias": "1.8.16", "tsx": "4.20.5", @@ -8829,9 +8829,9 @@ "license": "ISC" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "license": "MIT", "dependencies": { "minimist": "^1.2.5", @@ -11655,9 +11655,9 @@ } }, "node_modules/mongoose": { - "version": "8.19.2", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.19.2.tgz", - "integrity": "sha512-ww2T4dBV+suCbOfG5YPwj9pLCfUVyj8FEA1D3Ux1HHqutpLxGyOYEPU06iPRBW4cKr3PJfOSYsIpHWPTkz5zig==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz", + "integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==", "license": "MIT", "dependencies": { "bson": "^6.10.4", @@ -13660,9 +13660,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -14714,19 +14714,19 @@ } }, "node_modules/ts-jest": { - "version": "29.4.6", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", - "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", + "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.7.3", + "semver": "^7.7.4", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, @@ -14743,7 +14743,7 @@ "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" + "typescript": ">=4.3 <7" }, "peerDependenciesMeta": { "@babel/core": { diff --git a/server/package.json b/server/package.json index 88f90c68a8..af15c9ccec 100755 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,8 @@ "main": "index.js", "type": "module", "scripts": { - "test": "NODE_OPTIONS=--experimental-vm-modules c8 jest --runInBand", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --runInBand --config jest.config.ts", + "test:escalation": "NODE_OPTIONS=--experimental-vm-modules jest test/escalationRules.test.ts --runInBand --config jest.config.ts", "dev": "nodemon --exec tsx src/index.js", "start": "node --watch ./dist/index.js", "build": "tsc && tsc-alias && cp -r src/templates dist/templates", @@ -49,7 +50,7 @@ "jsonwebtoken": "9.0.2", "mailersend": "^2.2.0", "mjml": "^5.0.0-alpha.4", - "mongoose": "^8.3.3", + "mongoose": "^8.23.0", "multer": "^1.4.5-lts.1", "nodemailer": "8.0.1", "ping": "0.4.4", @@ -91,7 +92,7 @@ "lint-staged": "^16.2.7", "nodemon": "^3.1.11", "prettier": "^3.3.3", - "ts-jest": "^29.4.6", + "ts-jest": "^29.4.9", "ts-node": "^10.9.2", "tsc-alias": "1.8.16", "tsx": "4.20.5", diff --git a/server/src/config/services.ts b/server/src/config/services.ts index b31c8a5e91..4b3f16f93a 100644 --- a/server/src/config/services.ts +++ b/server/src/config/services.ts @@ -203,24 +203,9 @@ export const initializeServices = async ({ ]); const emailService = new EmailService(settingsService, fs, path, compile, mjml2html, nodemailer, logger); - const notificationMessageBuilder = new NotificationMessageBuilder(); - - const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder); - - const checkService = new CheckService(monitorsRepository, logger, checksRepository); - - const globalPingService = new GlobalPingService(logger); - - const geoChecksService = new GeoChecksService({ - logger, - geoChecksRepository, - globalPingService, - monitorsRepository, - }); - const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService); - const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository); + const notificationMessageBuilder = new NotificationMessageBuilder(); // Notification providers const webhookProvider = new WebhookProvider(logger); @@ -246,6 +231,23 @@ export const initializeServices = async ({ notificationMessageBuilder ); + const incidentService = new IncidentService(logger, incidentsRepository, monitorsRepository, usersRepository, notificationMessageBuilder, notificationsService); + + const checkService = new CheckService(monitorsRepository, logger, checksRepository); + + const globalPingService = new GlobalPingService(logger); + + const geoChecksService = new GeoChecksService({ + logger, + geoChecksRepository, + globalPingService, + monitorsRepository, + }); + + const bufferService = new BufferService(logger, checkService, geoChecksService, settingsService); + + const statusService = new StatusService(logger, bufferService, monitorsRepository, monitorStatsRepository, checksRepository); + const superSimpleQueueHelper = new SuperSimpleQueueHelper( logger, networkService, diff --git a/server/src/db/models/Monitor.ts b/server/src/db/models/Monitor.ts index 036aeadad6..48b3aebe79 100644 --- a/server/src/db/models/Monitor.ts +++ b/server/src/db/models/Monitor.ts @@ -355,6 +355,16 @@ const MonitorSchema = new Schema( type: [checkSnapshotSchema], default: [], }, + escalationRules: { + type: [ + { + delayMinutes: { type: Number, required: true }, + notificationChannelIds: [{ type: Schema.Types.ObjectId, ref: "Notification", required: true }], + messageTemplate: { type: String }, + }, + ], + default: [], + }, }, { timestamps: true, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index b2d7594483..373db75d2b 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -391,6 +391,7 @@ class MongoMonitorsRepository implements IMonitorsRepository { geoCheckEnabled: doc.geoCheckEnabled ?? false, geoCheckLocations: doc.geoCheckLocations ?? [], geoCheckInterval: doc.geoCheckInterval ?? 300000, + escalationRules: doc.escalationRules ?? [], createdAt: toDateString(doc.createdAt), updatedAt: toDateString(doc.updatedAt), }; diff --git a/server/src/service/business/incidentService.ts b/server/src/service/business/incidentService.ts index 4790f9aacc..c9734f889e 100644 --- a/server/src/service/business/incidentService.ts +++ b/server/src/service/business/incidentService.ts @@ -8,6 +8,7 @@ import type { Incident, IncidentSummary, User } from "@/types/index.js"; import type { MonitorActionDecision } from "@/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.js"; import type { INotificationMessageBuilder } from "@/service/infrastructure/notificationMessageBuilder.js"; import type { ILogger } from "@/utils/logger.js"; +import type { NotificationsService } from "@/service/infrastructure/notificationsService.js"; export interface IIncidentService { handleIncident( @@ -39,19 +40,22 @@ export class IncidentService implements IIncidentService { private monitorsRepository: IMonitorsRepository; private usersRepository: IUsersRepository; private notificationMessageBuilder: INotificationMessageBuilder; + private notificationsService: NotificationsService; constructor( logger: ILogger, incidentsRepository: IIncidentsRepository, monitorsRepository: IMonitorsRepository, usersRepository: IUsersRepository, - notificationMessageBuilder: INotificationMessageBuilder + notificationMessageBuilder: INotificationMessageBuilder, + notificationsService: NotificationsService ) { this.logger = logger; this.incidentsRepository = incidentsRepository; this.monitorsRepository = monitorsRepository; this.usersRepository = usersRepository; this.notificationMessageBuilder = notificationMessageBuilder; + this.notificationsService = notificationsService; } get serviceName() { @@ -91,7 +95,16 @@ export class IncidentService implements IIncidentService { statusCode, message, }; - return await this.incidentsRepository.create(incident); + const createdIncident = await this.incidentsRepository.create(incident); + // Schedule escalation notifications + if (monitor.escalationRules && monitor.escalationRules.length > 0 && monitorStatusResponse) { + this.notificationsService.scheduleEscalationNotifications( + monitor, + monitorStatusResponse, + decision + ); + } + return createdIncident; } } diff --git a/server/src/service/infrastructure/notificationsService.ts b/server/src/service/infrastructure/notificationsService.ts index c75477c88c..4936e65c73 100644 --- a/server/src/service/infrastructure/notificationsService.ts +++ b/server/src/service/infrastructure/notificationsService.ts @@ -14,7 +14,6 @@ export interface INotificationsService { updateById(id: string, teamId: string, updateData: Partial): Promise; deleteById: (id: string, teamId: string) => Promise; handleNotifications: (monitor: Monitor, monitorStatusResponse: MonitorStatusResponse, decision: MonitorActionDecision) => Promise; - sendTestNotification: (notification: Partial) => Promise; testAllNotifications: (notificationIds: string[]) => Promise; } @@ -197,4 +196,36 @@ export class NotificationsService implements INotificationsService { await this.monitorsRepository.removeNotificationFromMonitors(id); return deleted; }; + + public async scheduleEscalationNotifications( + monitor: Monitor, + monitorStatusResponse: MonitorStatusResponse, + decision: MonitorActionDecision + ): Promise { + if (!monitor.escalationRules || monitor.escalationRules.length === 0) return; + + const settings = this.settingsService.getSettings(); + const clientHost = settings.clientHost || "Host not defined"; + const notificationMessage = this.notificationMessageBuilder.buildMessage( + monitor, + monitorStatusResponse, + decision, + clientHost + ); + + for (const rule of monitor.escalationRules) { + setTimeout(async () => { + const notifications = await this.notificationsRepository.findNotificationsByIds(rule.notificationChannelIds); + const tasks = notifications.map((notification) => + this.send(notification, monitor, monitorStatusResponse, decision, notificationMessage) + ); + await Promise.all(tasks); + this.logger.info({ + service: SERVICE_NAME, + method: "scheduleEscalationNotifications", + message: `Escalation rule triggered after ${rule.delayMinutes} min for monitor ${monitor.id}`, + }); + }, rule.delayMinutes * 60 * 1000); + } + } } diff --git a/server/src/types/monitor.ts b/server/src/types/monitor.ts index f29ce75d78..a4c319c297 100644 --- a/server/src/types/monitor.ts +++ b/server/src/types/monitor.ts @@ -56,6 +56,15 @@ export interface Monitor { recentChecks: CheckSnapshot[]; createdAt: string; updatedAt: string; + /** + * Escalation rules for notifications. Each rule defines a delay (in minutes), + * a set of notification channel IDs, and an optional custom message template. + */ + escalationRules?: Array<{ + delayMinutes: number; + notificationChannelIds: string[]; + messageTemplate?: string; + }>; } export interface MonitorsSummary { diff --git a/server/src/validation/monitorValidation.ts b/server/src/validation/monitorValidation.ts index df000ecef2..3c130f5d64 100644 --- a/server/src/validation/monitorValidation.ts +++ b/server/src/validation/monitorValidation.ts @@ -78,6 +78,15 @@ export const createMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + escalationRules: z + .array( + z.object({ + delayMinutes: z.number().min(1), + notificationChannelIds: z.array(z.string().min(1)), + messageTemplate: z.string().optional(), + }) + ) + .optional(), }); export const editMonitorBodyValidation = z.object({ @@ -107,6 +116,15 @@ export const editMonitorBodyValidation = z.object({ geoCheckEnabled: z.boolean().optional(), geoCheckLocations: z.array(z.enum(GeoContinents)).optional(), geoCheckInterval: z.number().min(300000).optional(), + escalationRules: z + .array( + z.object({ + delayMinutes: z.number().min(1), + notificationChannelIds: z.array(z.string().min(1)), + messageTemplate: z.string().optional(), + }) + ) + .optional(), }); export const pauseMonitorParamValidation = z.object({ diff --git a/server/test/escalationRules.test.ts b/server/test/escalationRules.test.ts new file mode 100644 index 0000000000..86ffb148d9 --- /dev/null +++ b/server/test/escalationRules.test.ts @@ -0,0 +1,37 @@ + +import { jest } from "@jest/globals"; +import { IncidentService } from "../src/service/business/incidentService"; +import { NotificationsService } from "../src/service/infrastructure/notificationsService"; + +describe("Escalation Rules Integration", () => { + it("schedules escalation notifications according to escalationRules", async () => { + const mockSchedule = jest.fn(); + const notificationsService = { + scheduleEscalationNotifications: mockSchedule, + } as unknown as NotificationsService; + + const incidentService = new IncidentService( + {} as any, // logger + { findActiveByMonitorId: jest.fn(), create: jest.fn().mockResolvedValue({ id: "incident-1" }) } as any, // incidentsRepository + {} as any, // monitorsRepository + {} as any, // usersRepository + { extractThresholdBreaches: jest.fn() } as any, // notificationMessageBuilder + notificationsService + ); + + const monitor = { + id: "monitor-1", + teamId: "team-1", + escalationRules: [ + { delayMinutes: 1, notificationChannelIds: ["notif-1"] }, + { delayMinutes: 5, notificationChannelIds: ["notif-2"] }, + ], + } as any; + const decision = { shouldCreateIncident: true, shouldResolveIncident: false } as any; + const monitorStatusResponse = {} as any; + + await incidentService.handleIncident(monitor, 500, decision, monitorStatusResponse); + + expect(mockSchedule).toHaveBeenCalledWith(monitor, monitorStatusResponse, decision); + }); +}); diff --git a/server/test/migrate-escalationRules.js b/server/test/migrate-escalationRules.js new file mode 100644 index 0000000000..07935e9fcf --- /dev/null +++ b/server/test/migrate-escalationRules.js @@ -0,0 +1,18 @@ +// Migration script to add escalationRules to all monitors if missing +const mongoose = require('mongoose'); +const Monitor = require('../src/db/models/Monitor'); + +async function migrate() { + await mongoose.connect(process.env.MONGO_URI || 'mongodb://localhost:27017/checkmate'); + const result = await Monitor.updateMany( + { escalationRules: { $exists: false } }, + { $set: { escalationRules: [] } } + ); + console.log(`Updated ${result.nModified} monitors to add escalationRules array.`); + await mongoose.disconnect(); +} + +migrate().catch(err => { + console.error(err); + process.exit(1); +}); From 71ad934d2dc05a381621bee4845194de55fddc21 Mon Sep 17 00:00:00 2001 From: justins Date: Fri, 10 Apr 2026 23:06:13 -0400 Subject: [PATCH 2/2] deleted implementation plan --- ESCALATION_IMPLEMENTATION_PLAN.md | 114 ------------------------------ 1 file changed, 114 deletions(-) delete mode 100644 ESCALATION_IMPLEMENTATION_PLAN.md diff --git a/ESCALATION_IMPLEMENTATION_PLAN.md b/ESCALATION_IMPLEMENTATION_PLAN.md deleted file mode 100644 index 35989e73d0..0000000000 --- a/ESCALATION_IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,114 +0,0 @@ -# Escalated Notifications Implementation Plan - -## Overview -This document outlines the implementation plan for adding escalated notifications to monitors in the Checkmate project. The goal is to allow users to define escalation rules for each monitor, persist these rules in the backend, edit them in the frontend, and integrate escalation logic into the notification and incident lifecycle. - ---- - -## 1. Data Model Changes - -### Backend -- **File:** `server/src/types/monitor.ts` - - Add an `escalationRules` field to the `Monitor` interface. Example: - ```ts - escalationRules?: Array<{ - delayMinutes: number; // Minutes after initial alert to escalate - notificationChannelIds: string[]; // IDs of notification channels to use - messageTemplate?: string; // Optional custom message - }>; - ``` -- **File:** `server/src/db/models/Monitor.ts` - - Update the Mongoose schema to include `escalationRules` as an array of subdocuments. -- **File:** `server/src/validation/monitorValidation.ts` - - Update Zod schemas for monitor create/edit to validate the new `escalationRules` field. - -### Frontend -- **File:** `client/src/Types/Monitor.ts` - - Add `escalationRules` to the `Monitor` type. -- **File:** `client/src/Validation/monitor.ts` - - Update Zod schemas for monitor forms to support `escalationRules`. - ---- - -## 2. API Changes -- Ensure monitor create/update endpoints accept and persist `escalationRules`. -- Update OpenAPI spec (`server/openapi.json`) if present. - ---- - -## 3. UI/UX Changes - -### Monitor Create/Edit -- **File:** `client/src/Pages/CreateMonitor/index.tsx` - - Add UI section for defining escalation rules: - - Allow adding multiple escalation steps (delay, channels, message). - - Use notification channels from `/notifications/team`. - - Validate and submit escalation rules as part of monitor form. -- **File:** `client/src/Hooks/useMonitorForm.ts` - - Add logic to handle escalation rules in form state and defaults. - -### Monitor Details (Optional) -- Display escalation rules in monitor details view for transparency. - ---- - -## 4. Notification & Incident Logic - -### Backend -- **File:** `server/src/service/business/incidentService.ts` - - On incident creation, schedule escalation notifications based on rules. - - Track escalation state (e.g., which steps have been sent) in incident or a new collection. -- **File:** `server/src/service/infrastructure/notificationsService.ts` - - Add logic to send escalation notifications at the correct time. - - Ensure deduplication and correct channel targeting. - -### Data Persistence -- Consider tracking escalation progress in the incident document or a new escalation-tracking collection. - ---- - -## 5. Testing & Validation -- Update/create tests for monitor creation, update, and escalation logic. -- Test UI for adding, editing, and displaying escalation rules. -- Test notification dispatch and escalation timing. - ---- - -## 6. Migration -- Write a migration script if needed to add `escalationRules` to existing monitors (default: empty array). - ---- - -## 7. Affected Files Summary -- `server/src/types/monitor.ts` -- `server/src/db/models/Monitor.ts` -- `server/src/validation/monitorValidation.ts` -- `server/src/service/business/incidentService.ts` -- `server/src/service/infrastructure/notificationsService.ts` -- `client/src/Types/Monitor.ts` -- `client/src/Validation/monitor.ts` -- `client/src/Pages/CreateMonitor/index.tsx` -- `client/src/Hooks/useMonitorForm.ts` -- (Optional) `client/src/Pages/MonitorDetails/` -- (Optional) Migration script location - ---- - -## 8. Open Questions -- Where to persist escalation state: incident doc or new collection? -- Should escalations be visible in incident history UI? -- Should escalations support custom messages per step? - ---- - -## 9. Next Steps -1. Confirm data model and API changes. -2. Implement backend model/schema/validation updates. -3. Implement frontend type/schema/form updates. -4. Add UI for escalation rules in monitor create/edit. -5. Integrate escalation logic into incident/notification services. -6. Test end-to-end. - ---- - -*This plan is based on the current Checkmate repo structure and code inspection as of this writing.*