Merge pull request #3376 from ashu130698/feat/bulk-edit-notifications

feat: add bulk edit monitor notifications endpoint
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-03-23 10:11:12 -07:00
commit 0a6d876f4f
10 files changed
+131 -30

No files matched your search

+1 -1
View File
@@ -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),
+44 -3
View File
@@ -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<Response | void>;
getAllGames: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
getGroupsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
updateNotifications: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
}
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;
@@ -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 });
}
@@ -41,6 +41,7 @@ export interface IMonitorsRepository {
findMonitorsSummaryByTeamId(teamId: string, config?: SummaryConfig): Promise<MonitorsSummary>;
findGroupsByTeamId(teamId: string): Promise<string[]>;
removeNotificationFromMonitors(notificationId: string): Promise<void>;
updateNotifications(teamId: string, monitorIds: string[], notificationIds: string[], action: "add" | "remove" | "set"): Promise<number>;
deleteByTeamIdsNotIn(teamIds: string[]): Promise<number>;
findAllMonitorIds(): Promise<string[]>;
}
@@ -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<number> => {
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 [];
+1
View File
@@ -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);
@@ -79,6 +79,9 @@ export interface IMonitorService {
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
deleteAllMonitors(args: { teamId: string }): Promise<number>;
// notifications
updateNotifications(args: { teamId: string; monitorIds: string[]; notificationIds: string[]; action: "add" | "remove" | "set" }): Promise<number>;
// other
exportMonitorsToJSON(args: { teamId: string }): Promise<Monitor[]>;
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<number> => {
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<Monitor> => {
const monitor = await this.monitorsRepository.togglePauseById(monitorId, teamId);
if (monitor.isActive) {
+2 -2
View File
@@ -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",
@@ -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"],
}
);