feat(monitors): add bulk pause/resume api endpoints and service logic
- Validates bulk monitor IDs using zod - Implements optimized Mongo $in/$ne queries for performance - Orchestrates background job pausing/resuming via BullMQ - Added comprehensive unit tests for monitor service logic Fixes #3602
This commit is contained in:
1 parent
28baeaf218
commit
094def117a
7 files changed
+166
-1
No files matched your search
@@ -14,12 +14,12 @@ import {
|
||||
getUptimeDetailsByIdParamValidation,
|
||||
getUptimeDetailsByIdQueryValidation,
|
||||
importMonitorsBodyValidation,
|
||||
bulkPauseMonitorBodyValidation,
|
||||
} from "@/validation/monitorValidation.js";
|
||||
import sslChecker from "ssl-checker";
|
||||
import { fetchMonitorCertificate, requireTeamId, requireUserId } from "@/controllers/controllerUtils.js";
|
||||
import { AppError } from "@/utils/AppError.js";
|
||||
import { IMonitorService, INotificationsService } from "@/service/index.js";
|
||||
import { GeoContinent } from "@/types/geoCheck.js";
|
||||
|
||||
const SERVICE_NAME = "monitorController";
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface IMonitorController {
|
||||
deleteAllMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
editMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
pauseMonitor: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
bulkPauseMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
addDemoMonitors: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
getMonitorsByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
getMonitorsWithChecksByTeamId: (req: Request, res: Response, next: NextFunction) => Promise<Response | void>;
|
||||
@@ -307,6 +308,27 @@ class MonitorController implements IMonitorController {
|
||||
}
|
||||
};
|
||||
|
||||
bulkPauseMonitors = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const validatedBody = bulkPauseMonitorBodyValidation.parse(req.body);
|
||||
|
||||
const { monitorIds, pause } = validatedBody;
|
||||
const teamId = requireTeamId(req.user?.teamId);
|
||||
|
||||
const monitors = await this.monitorService.bulkPauseMonitors({ teamId, monitorIds, pause });
|
||||
|
||||
const action = pause ? "paused" : "resumed";
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
msg: `${monitors.length} monitor(s) ${action} successfully`,
|
||||
data: monitors,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
addDemoMonitors = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = requireUserId(req.user?.id);
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface IMonitorsRepository {
|
||||
statusPatch?: Partial<Monitor>
|
||||
): Promise<Monitor>;
|
||||
togglePauseById(monitorId: string, teamId: string): Promise<Monitor>;
|
||||
bulkTogglePause(monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]>;
|
||||
// delete
|
||||
deleteById(monitorId: string, teamId: string): Promise<Monitor>;
|
||||
deleteByTeamId(teamId: string): Promise<{ monitors: Monitor[]; deletedCount: number }>;
|
||||
|
||||
@@ -234,6 +234,31 @@ class MongoMonitorsRepository implements IMonitorsRepository {
|
||||
return this.toEntity(monitor);
|
||||
};
|
||||
|
||||
bulkTogglePause = async (monitorIds: string[], teamId: string, pause: boolean): Promise<Monitor[]> => {
|
||||
const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id));
|
||||
const filter = {
|
||||
_id: { $in: objectIds },
|
||||
teamId: new mongoose.Types.ObjectId(teamId),
|
||||
isActive: pause, // Only pause if active (true), only resume if inactive (false)
|
||||
};
|
||||
|
||||
const eligibleIds = (await MonitorModel.find(filter).select("_id").lean()).map((doc) => doc._id);
|
||||
if (eligibleIds.length === 0) return [];
|
||||
|
||||
await MonitorModel.updateMany(
|
||||
{ _id: { $in: eligibleIds } },
|
||||
{
|
||||
$set: {
|
||||
isActive: !pause,
|
||||
status: pause ? "paused" : "initializing",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const updatedMonitors = await MonitorModel.find({ _id: { $in: eligibleIds } });
|
||||
return this.mapDocuments(updatedMonitors);
|
||||
};
|
||||
|
||||
deleteById = async (monitorId: string, teamId: string) => {
|
||||
const deletedMonitor = await MonitorModel.findOneAndDelete({ _id: monitorId, teamId });
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ class MonitorRoutes {
|
||||
|
||||
// General monitor routes
|
||||
this.router.post("/pause/:monitorId", isAllowed(["admin", "superadmin"]), this.monitorController.pauseMonitor);
|
||||
this.router.post("/bulk/pause", isAllowed(["admin", "superadmin"]), this.monitorController.bulkPauseMonitors);
|
||||
|
||||
// Util routes
|
||||
this.router.get("/certificate/:monitorId", (req, res, next) => {
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface IMonitorService {
|
||||
// update
|
||||
editMonitor(args: { teamId: string; monitorId: string; body: Partial<Monitor> }): Promise<Monitor>;
|
||||
pauseMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
|
||||
bulkPauseMonitors(args: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<Monitor[]>;
|
||||
|
||||
// delete
|
||||
deleteMonitor(args: { teamId: string; monitorId: string }): Promise<Monitor>;
|
||||
@@ -462,6 +463,22 @@ export class MonitorService implements IMonitorService {
|
||||
return monitor;
|
||||
};
|
||||
|
||||
bulkPauseMonitors = async ({ teamId, monitorIds, pause }: { teamId: string; monitorIds: string[]; pause: boolean }): Promise<Monitor[]> => {
|
||||
const monitors = await this.monitorsRepository.bulkTogglePause(monitorIds, teamId, pause);
|
||||
|
||||
await Promise.all(
|
||||
monitors.map(async (monitor) => {
|
||||
if (monitor.isActive) {
|
||||
await this.jobQueue.resumeJob(monitor);
|
||||
} else {
|
||||
await this.jobQueue.pauseJob(monitor);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return monitors;
|
||||
};
|
||||
|
||||
deleteMonitor = async ({ teamId, monitorId }: { teamId: string; monitorId: string }): Promise<Monitor> => {
|
||||
const monitor = await this.monitorsRepository.deleteById(monitorId, teamId);
|
||||
await this.monitorStatsRepository.deleteByMonitorId(monitor.id).catch((err: unknown) => {
|
||||
|
||||
@@ -113,6 +113,14 @@ export const pauseMonitorParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
});
|
||||
|
||||
export const bulkPauseMonitorBodyValidation = z.object({
|
||||
monitorIds: z
|
||||
.array(z.string().min(1, "Monitor ID must not be empty"))
|
||||
.min(1, "At least one monitor ID is required")
|
||||
.max(100, "Cannot bulk update more than 100 monitors at once"),
|
||||
pause: z.boolean(),
|
||||
});
|
||||
|
||||
export const getUptimeDetailsByIdParamValidation = z.object({
|
||||
monitorId: z.string().min(1, "Monitor ID is required"),
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ const createMonitorsRepositoryMock = () =>
|
||||
updateById: jest.fn(),
|
||||
updateNotifications: jest.fn(),
|
||||
togglePauseById: jest.fn(),
|
||||
bulkTogglePause: jest.fn(),
|
||||
deleteById: jest.fn(),
|
||||
deleteByTeamId: jest.fn(),
|
||||
}) as unknown as IMonitorsRepository;
|
||||
@@ -839,6 +840,96 @@ describe("MonitorService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkPauseMonitors", () => {
|
||||
it("resumes jobs for monitors that are active after bulk toggle", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitors = [makeMonitor({ id: "m1", isActive: true }), makeMonitor({ id: "m2", isActive: true })];
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors);
|
||||
const { service, jobQueue } = createService({ monitorsRepository });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1", "m2"],
|
||||
pause: false,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, false);
|
||||
expect(jobQueue.resumeJob).toHaveBeenCalledTimes(2);
|
||||
expect(jobQueue.pauseJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pauses jobs for monitors that are inactive after bulk toggle", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitors = [makeMonitor({ id: "m1", isActive: false }), makeMonitor({ id: "m2", isActive: false })];
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue(monitors);
|
||||
const { service, jobQueue } = createService({ monitorsRepository });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1", "m2"],
|
||||
pause: true,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(monitorsRepository.bulkTogglePause).toHaveBeenCalledWith(["m1", "m2"], TEAM_ID, true);
|
||||
expect(jobQueue.pauseJob).toHaveBeenCalledTimes(2);
|
||||
expect(jobQueue.resumeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles mixed active/inactive monitors correctly", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const activeMonitor = makeMonitor({ id: "m1", isActive: true });
|
||||
const pausedMonitor = makeMonitor({ id: "m2", isActive: false });
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([activeMonitor, pausedMonitor]);
|
||||
const { service, jobQueue } = createService({ monitorsRepository });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1", "m2"],
|
||||
pause: true,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(jobQueue.resumeJob).toHaveBeenCalledTimes(1);
|
||||
expect(jobQueue.resumeJob).toHaveBeenCalledWith(activeMonitor);
|
||||
expect(jobQueue.pauseJob).toHaveBeenCalledTimes(1);
|
||||
expect(jobQueue.pauseJob).toHaveBeenCalledWith(pausedMonitor);
|
||||
});
|
||||
|
||||
it("returns empty array when no monitors were affected", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([]);
|
||||
const { service, jobQueue } = createService({ monitorsRepository });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1", "m2"],
|
||||
pause: true,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(jobQueue.pauseJob).not.toHaveBeenCalled();
|
||||
expect(jobQueue.resumeJob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("works with a single monitor", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
const monitor = makeMonitor({ id: "m1", isActive: false });
|
||||
(monitorsRepository.bulkTogglePause as jest.Mock).mockResolvedValue([monitor]);
|
||||
const { service, jobQueue } = createService({ monitorsRepository });
|
||||
|
||||
const result = await service.bulkPauseMonitors({
|
||||
teamId: TEAM_ID,
|
||||
monitorIds: ["m1"],
|
||||
pause: true,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(jobQueue.pauseJob).toHaveBeenCalledWith(monitor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteMonitor", () => {
|
||||
it("deletes monitor and all associated data", async () => {
|
||||
const monitorsRepository = createMonitorsRepositoryMock();
|
||||
|
||||
Reference in new issue
Block a user