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); +});