diff --git a/client/package-lock.json b/client/package-lock.json index 9cf2824ae..7462d95d7 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -83,7 +83,6 @@ "node_modules/@babel/core": { "version": "7.29.0", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -354,7 +353,6 @@ "node_modules/@emotion/react": { "version": "11.14.0", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -392,7 +390,6 @@ "node_modules/@emotion/styled": { "version": "11.14.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1162,7 +1159,6 @@ "node_modules/@mui/material": { "version": "7.3.7", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.7", @@ -1267,7 +1263,6 @@ "node_modules/@mui/system": { "version": "7.3.7", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/private-theming": "^7.3.7", @@ -2308,7 +2303,6 @@ "version": "24.5.2", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.12.0" } @@ -2324,7 +2318,6 @@ "node_modules/@types/react": { "version": "18.3.27", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2446,7 +2439,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2771,7 +2763,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3203,8 +3194,7 @@ }, "node_modules/dayjs": { "version": "1.11.13", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", @@ -3586,7 +3576,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -4331,7 +4320,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -5115,7 +5103,6 @@ "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz", "integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", @@ -5763,7 +5750,6 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5775,7 +5761,6 @@ "node_modules/react-hook-form": { "version": "7.71.1", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5975,8 +5960,7 @@ }, "node_modules/redux": { "version": "5.0.1", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-persist": { "version": "6.0.0", @@ -6122,11 +6106,10 @@ "license": "Unlicense" }, "node_modules/rollup": { - "version": "4.59.0", + "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6915,7 +6898,6 @@ "version": "5.9.3", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7051,7 +7033,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/server/src/config/controllers.ts b/server/src/config/controllers.ts index 98e6ded8e..00769f545 100644 --- a/server/src/config/controllers.ts +++ b/server/src/config/controllers.ts @@ -31,7 +31,7 @@ export interface InitializedControllers { export const initializeControllers = (services: InitializedServices): InitializedControllers => { return { authController: new AuthController(services.userService), - monitorController: new MonitorController(services.monitorService), + monitorController: new MonitorController(services.monitorService, services.notificationsService), settingsController: new SettingsController(services.settingsService, services.emailService), checkController: new CheckController(services.checkService), geoCheckController: new GeoCheckController(services.geoChecksService), diff --git a/server/src/controllers/monitorController.ts b/server/src/controllers/monitorController.ts index b3b03ed57..7f1136b7c 100644 --- a/server/src/controllers/monitorController.ts +++ b/server/src/controllers/monitorController.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; - +import { updateNotificationsValidation } from "@/validation/notificationValidation.js"; import { getMonitorByIdParamValidation, getMonitorByIdQueryValidation, @@ -17,7 +17,9 @@ import { } from "@/validation/monitorValidation.js"; import sslChecker from "ssl-checker"; import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js"; -import { IMonitorService } from "@/service/index.js"; +import { AppError } from "@/utils/AppError.js"; +import { IMonitorService, INotificationsService } from "@/service/index.js"; +import { GeoContinent } from "@/types/geoCheck.js"; const SERVICE_NAME = "monitorController"; @@ -40,14 +42,17 @@ export interface IMonitorController { exportMonitorsToJSON: (req: Request, res: Response, next: NextFunction) => Promise; getAllGames: (req: Request, res: Response, next: NextFunction) => Promise; getGroupsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise; + updateNotifications: (req: Request, res: Response, next: NextFunction) => Promise; } class MonitorController implements IMonitorController { static SERVICE_NAME = SERVICE_NAME; private monitorService: IMonitorService; + private notificationsService: INotificationsService; - constructor(monitorService: IMonitorService) { + constructor(monitorService: IMonitorService, notificationsService: INotificationsService) { this.monitorService = monitorService; + this.notificationsService = notificationsService; } get serviceName() { @@ -412,6 +417,42 @@ class MonitorController implements IMonitorController { next(error); } }; + + updateNotifications = async (req: Request, res: Response, next: NextFunction) => { + try { + updateNotificationsValidation.parse(req.body); + + const teamId = requireTeamId(req.user?.teamId); + const { monitorIds, notificationIds, action } = req.body; + + // Verify all requested notification IDs actually belong to this team + const teamNotifications = await this.notificationsService.findNotificationsByTeamId(teamId); + const validNotificationIds = teamNotifications.map((n) => n.id); + + const invalidIds = notificationIds.filter((id: string) => !validNotificationIds.includes(id)); + if (invalidIds.length > 0) { + throw new AppError({ + message: `The following notification IDs are invalid or do not belong to your team: ${invalidIds.join(", ")}`, + status: 403, + }); + } + + const modifiedCount = await this.monitorService.updateNotifications({ + teamId, + monitorIds, + notificationIds, + action, + }); + + return res.status(200).json({ + success: true, + msg: `Notifications updated successfully on ${modifiedCount} monitor(s)`, + data: { modifiedCount }, + }); + } catch (error) { + next(error); + } + }; } export default MonitorController; diff --git a/server/src/controllers/notificationController.ts b/server/src/controllers/notificationController.ts index 979e4afc9..0a49cbdb1 100644 --- a/server/src/controllers/notificationController.ts +++ b/server/src/controllers/notificationController.ts @@ -9,9 +9,9 @@ import { testAllNotificationsBodyValidation, } from "@/validation/notificationValidation.js"; import { AppError } from "@/utils/AppError.js"; -import { IMonitorsRepository } from "@/repositories/index.js"; import { INotificationsService } from "@/service/index.js"; import { requireTeamId, requireUserId } from "./controllerUtils.js"; +import { IMonitorsRepository } from "@/repositories/index.js"; const SERVICE_NAME = "NotificationController"; @@ -142,8 +142,8 @@ class NotificationController implements INotificationController { const teamId = requireTeamId(req.user?.teamId); const monitor = await this.monitorsRepository.findById(validatedBody.monitorId, teamId); + const notifications = monitor.notifications || []; - const notifications = monitor.notifications; if (notifications.length === 0) { throw new AppError({ message: "No notifications", status: 400 }); } diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 9d82d9987..8c5f63e7d 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -41,6 +41,7 @@ export interface IMonitorsRepository { findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise; findGroupsByTeamId(teamId: string): Promise; removeNotificationFromMonitors(notificationId: string): Promise; + updateNotifications(teamId: string, monitorIds: string[], notificationIds: string[], action: "add" | "remove" | "set"): Promise; deleteByTeamIdsNotIn(teamIds: string[]): Promise; findAllMonitorIds(): Promise; } diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 3269dbe3f..b2d759448 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -296,6 +296,41 @@ class MongoMonitorsRepository implements IMonitorsRepository { await MonitorModel.updateMany({ notifications: notificationId }, { $pull: { notifications: notificationId } }); }; + updateNotifications = async ( + teamId: string, + monitorIds: string[], + notificationIds: string[], + action: "add" | "remove" | "set" + ): Promise => { + let objectIds; + let notificationObjectIds; + try { + objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + notificationObjectIds = notificationIds.map((id) => new mongoose.Types.ObjectId(id)); + } catch { + throw new AppError({ message: "One or more monitor or notification IDs are invalid", status: 400 }); + } + const filter = { _id: { $in: objectIds }, teamId: new mongoose.Types.ObjectId(teamId) }; + + let update; + switch (action) { + case "add": + update = { $addToSet: { notifications: { $each: notificationObjectIds } } }; + break; + case "remove": + update = { $pull: { notifications: { $in: notificationObjectIds } } }; + break; + case "set": + update = { $set: { notifications: notificationObjectIds } }; + break; + default: + throw new AppError({ message: `Invalid action: ${action}`, status: 400 }); + } + + const result = await MonitorModel.updateMany(filter, update); + return result.modifiedCount; + }; + private mapDocuments = (documents: MonitorDocument[]): Monitor[] => { if (!documents?.length) { return []; diff --git a/server/src/routes/monitorRoute.ts b/server/src/routes/monitorRoute.ts index 2e994fd01..277cd250b 100755 --- a/server/src/routes/monitorRoute.ts +++ b/server/src/routes/monitorRoute.ts @@ -38,6 +38,7 @@ class MonitorRoutes { }); // General monitor CRUD routes + this.router.patch("/notifications", isAllowed(["admin", "superadmin"]), this.monitorController.updateNotifications); this.router.post("/", isAllowed(["admin", "superadmin"]), this.monitorController.createMonitor); this.router.delete("/", isAllowed(["superadmin"]), this.monitorController.deleteAllMonitors); diff --git a/server/src/service/business/monitorService.ts b/server/src/service/business/monitorService.ts index 8d56fae20..71c9d9d90 100644 --- a/server/src/service/business/monitorService.ts +++ b/server/src/service/business/monitorService.ts @@ -79,6 +79,9 @@ export interface IMonitorService { deleteMonitor(args: { teamId: string; monitorId: string }): Promise; deleteAllMonitors(args: { teamId: string }): Promise; + // notifications + updateNotifications(args: { teamId: string; monitorIds: string[]; notificationIds: string[]; action: "add" | "remove" | "set" }): Promise; + // other exportMonitorsToJSON(args: { teamId: string }): Promise; importMonitorsFromJSON(args: { teamId: string; userId: string; monitors: ImportedMonitor[] }): Promise<{ imported: number; errors: string[] }>; @@ -439,6 +442,28 @@ export class MonitorService implements IMonitorService { return editedMonitor; }; + updateNotifications = async ({ + teamId, + monitorIds, + notificationIds, + action, + }: { + teamId: string; + monitorIds: string[]; + notificationIds: string[]; + action: "add" | "remove" | "set"; + }): Promise => { + const modifiedCount = await this.monitorsRepository.updateNotifications(teamId, monitorIds, notificationIds, action); + + // If notifications were updated, we should update the jobs in the queue + if (modifiedCount > 0) { + const monitors = await this.monitorsRepository.findByIds(monitorIds); + await Promise.all(monitors.map((monitor) => this.jobQueue.updateJob(monitor))); + } + + return modifiedCount; + }; + pauseMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise => { const monitor = await this.monitorsRepository.togglePauseById(monitorId, teamId); if (monitor.isActive) { diff --git a/server/src/service/business/userService.ts b/server/src/service/business/userService.ts index e5b6f9e68..48ee5cfbf 100644 --- a/server/src/service/business/userService.ts +++ b/server/src/service/business/userService.ts @@ -143,7 +143,7 @@ export class UserService implements IUserService { user.password = this.hashPassword(user.password); } - const newUser = await this.usersRepository.create(user, file); + const newUser = await this.usersRepository.create(user as User, file); this.logger.debug({ message: "New user created", @@ -207,7 +207,7 @@ export class UserService implements IUserService { userData.password = this.hashPassword(userData.password); } - const newUser = await this.usersRepository.create(userData, file); + const newUser = await this.usersRepository.create(userData as User, file); this.logger.debug({ message: "New user created by superadmin", diff --git a/server/src/validation/notificationValidation.ts b/server/src/validation/notificationValidation.ts index 9ae4e9fdc..7e1491a9b 100644 --- a/server/src/validation/notificationValidation.ts +++ b/server/src/validation/notificationValidation.ts @@ -98,3 +98,20 @@ export const sendTestEmailBodyValidation = z.object({ systemEmailRejectUnauthorized: z.boolean().optional(), systemEmailTLSServername: z.union([z.string(), z.literal("")]).optional(), }); + +export const updateNotificationsValidation = z + .object({ + monitorIds: z.array(z.string()).min(1, "At least one monitor ID is required"), + notificationIds: z.array(z.string()), + action: z.enum(["add", "remove", "set"] as const), + }) + .refine( + (data) => { + if (data.action !== "set" && data.notificationIds.length === 0) return false; + return true; + }, + { + message: "Notification IDs cannot be empty unless action is 'set'", + path: ["notificationIds"], + } + );