From 8c7b691fda55f71a28d5f9562d5d74ab4cf0a4da Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 21 May 2026 09:59:23 -0700 Subject: [PATCH 1/8] add updatebyids to monitor repository --- server/src/repositories/monitors/IMonitorsRepository.ts | 1 + server/src/repositories/monitors/MongoMonitorsRepository.ts | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index c52ba55c0..6e2e9dea1 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -31,6 +31,7 @@ export interface IMonitorsRepository { // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; + updateByIds(monitorIds: string[], teamId: string, updates: Partial): Promise; updateStatusWindowAndChecks( monitorId: string, teamId: string, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 10bd57d56..5f6bbac73 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -190,6 +190,12 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(updatedMonitor); }; + updateByIds = async (monitorIds: string[], teamId: string, updates: Partial) => { + const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); + const updated = await MonitorModel.updateMany({ _id: { $in: objectIds }, teamId }, { $set: updates }, { runValidators: true }); + return updated.modifiedCount; + }; + updateStatusWindowAndChecks = async ( monitorId: string, teamId: string, From 46c80985ec50f515ff40e8994ad9aa905bb06088 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 21 May 2026 09:59:42 -0700 Subject: [PATCH 2/8] add findByIds to MW repository --- .../IMaintenanceWindowsRepository.ts | 1 + .../MongoMaintenanceWindowsRepository.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts index fca63ac35..0f4ad66a2 100644 --- a/server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/IMaintenanceWindowsRepository.ts @@ -5,6 +5,7 @@ export interface IMaintenanceWindowsRepository { // fetch findById(id: string, teamId: string): Promise; findByMonitorId(monitorId: string, teamId: string): Promise; + findByMonitorIds(monitorIds: string[], teamId: string, excludeId?: string): Promise; findByTeamId(teamId: string, page: number, rowsPerPage: number, field?: string, order?: string, active?: boolean): Promise; // update diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts index 635ddc2de..c3f94f55d 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts @@ -65,6 +65,18 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository return this.toEntity(maintenanceWindow); }; + findByMonitorIds = async (monitorIds: string[], teamId: string, excludeId?: string): Promise => { + const query: Record = { + monitorIds: { $in: monitorIds }, + teamId, + }; + if (excludeId) { + query._id = { $ne: new mongoose.Types.ObjectId(excludeId) }; + } + const maintenanceWindows = await MaintenanceWindowModel.find(query); + return this.mapDocuments(maintenanceWindows); + }; + findByMonitorId = async (monitorId: string, teamId: string): Promise => { const maintenanceWindows = await MaintenanceWindowModel.find({ monitorIds: monitorId, @@ -113,6 +125,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository if (!updated) { throw new AppError({ message: "Maintenance window not found or could not be updated", status: 404 }); } + return this.toEntity(updated); }; @@ -124,6 +137,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository if (!deleted) { throw new AppError({ message: "Maintenance window not found or could not be deleted", status: 404 }); } + return this.toEntity(deleted); }; countByTeamId = async (teamId: string, active?: boolean) => { From 85196852f4e7cb0ea0ad67ad2bd2df2ab6405db5 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 21 May 2026 10:02:39 -0700 Subject: [PATCH 3/8] extract active maintenance window check --- server/src/utils/maintenanceWindow.ts | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 server/src/utils/maintenanceWindow.ts diff --git a/server/src/utils/maintenanceWindow.ts b/server/src/utils/maintenanceWindow.ts new file mode 100644 index 000000000..aab4efd64 --- /dev/null +++ b/server/src/utils/maintenanceWindow.ts @@ -0,0 +1,28 @@ +import type { MaintenanceWindow } from "@/types/index.js"; + +export const isWindowActive = (window: MaintenanceWindow, now: Date = new Date()): boolean => { + if (!window.active) { + return false; + } + + const start = new Date(window.start); + const end = new Date(window.end); + const repeatInterval = window.repeat || 0; + + if (start <= now && end >= now) { + return true; + } + + // For a recurring window whose first occurrence is in the past, + // advance start/end by the repeat interval until we either catch + // an occurrence covering `now` or move past it. + while (start < now && repeatInterval !== 0) { + start.setTime(start.getTime() + repeatInterval); + end.setTime(end.getTime() + repeatInterval); + if (start <= now && end >= now) { + return true; + } + } + + return false; +}; From 4f571a258c622125a74e346f843de10385e46495 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 21 May 2026 10:02:56 -0700 Subject: [PATCH 4/8] update scheduler to use extacted method --- .../SuperSimpleQueueHelper.ts | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts index 956d19c98..603379190 100644 --- a/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts +++ b/server/src/service/infrastructure/SuperSimpleQueue/SuperSimpleQueueHelper.ts @@ -11,7 +11,8 @@ import { IncidentService, type IGeoChecksService, } from "@/service/index.js"; -import { CHECK_TTL_SENTINEL, type MaintenanceWindow, type StatusChangeResult } from "@/types/index.js"; +import { CHECK_TTL_SENTINEL, type StatusChangeResult } from "@/types/index.js"; +import { isWindowActive } from "@/utils/maintenanceWindow.js"; import { IMaintenanceWindowsRepository, IMonitorsRepository, @@ -358,32 +359,8 @@ export class SuperSimpleQueueHelper implements ISuperSimpleQueueHelper { async isInMaintenanceWindow(monitorId: string, teamId: string) { const maintenanceWindows = await this.maintenanceWindowsRepository.findByMonitorId(monitorId, teamId); - // Check for active maintenance window: - const maintenanceWindowIsActive = maintenanceWindows.reduce((acc: boolean, window: MaintenanceWindow) => { - if (window.active) { - const start = new Date(window.start); - const end = new Date(window.end); - const now = new Date(); - const repeatInterval = window.repeat || 0; - - // If start is < now and end > now, we're in maintenance - if (start <= now && end >= now) return true; - - // If maintenance window was set in the past with a repeat, - // we need to advance start and end to see if we are in range - - while (start < now && repeatInterval !== 0) { - start.setTime(start.getTime() + repeatInterval); - end.setTime(end.getTime() + repeatInterval); - if (start <= now && end >= now) { - return true; - } - } - return acc; - } - return acc; - }, false); - return maintenanceWindowIsActive; + const now = new Date(); + return maintenanceWindows.some((window) => isWindowActive(window, now)); } getCleanupRetentionJob = () => { From abf66eed9506740d75ac2957485801ed12996290 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 21 May 2026 10:23:27 -0700 Subject: [PATCH 5/8] add logic for entering/leaving maintenance windows, add tests --- .../business/maintenanceWindowService.ts | 34 +++- .../services/maintenanceWindowService.test.ts | 156 ++++++++++++++++++ .../test/unit/utils/maintenanceWindow.test.ts | 95 +++++++++++ 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 server/test/unit/utils/maintenanceWindow.test.ts diff --git a/server/src/service/business/maintenanceWindowService.ts b/server/src/service/business/maintenanceWindowService.ts index e56ad3757..541b028f5 100644 --- a/server/src/service/business/maintenanceWindowService.ts +++ b/server/src/service/business/maintenanceWindowService.ts @@ -1,6 +1,7 @@ import { IMaintenanceWindowsRepository, IMonitorsRepository } from "@/repositories/index.js"; import type { DurationUnit, MaintenanceWindow } from "@/types/index.js"; import { AppError } from "@/utils/AppError.js"; +import { isWindowActive } from "@/utils/maintenanceWindow.js"; const SERVICE_NAME = "maintenanceWindowService"; @@ -145,6 +146,8 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { teamId: string; body: Partial> & { monitors?: string[] }; }) => { + const existing = await this.maintenanceWindowsRepository.findById(id, teamId); + const { monitors, ...rest } = body; const update: Partial = rest; @@ -162,6 +165,35 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { update.monitorIds = monitors; } - return await this.maintenanceWindowsRepository.updateById(id, teamId, update); + const updated = await this.maintenanceWindowsRepository.updateById(id, teamId, update); + + const now = new Date(); + const wasActive = isWindowActive(existing, now); + const isActive = isWindowActive(updated, now); + + const enteringMaintenance = isActive ? updated.monitorIds.filter((monitorId) => !wasActive || !existing.monitorIds.includes(monitorId)) : []; + + const leavingCandidates = wasActive ? existing.monitorIds.filter((monitorId) => !isActive || !updated.monitorIds.includes(monitorId)) : []; + + if (enteringMaintenance.length > 0) { + await this.monitorsRepository.updateByIds(enteringMaintenance, teamId, { status: "maintenance" }); + } + + if (leavingCandidates.length > 0) { + const otherWindows = await this.maintenanceWindowsRepository.findByMonitorIds(leavingCandidates, teamId, existing.id); + const stillCovered = new Set(); + for (const otherWindow of otherWindows) { + if (!isWindowActive(otherWindow, now)) continue; + for (const monitorId of otherWindow.monitorIds) { + if (leavingCandidates.includes(monitorId)) stillCovered.add(monitorId); + } + } + const toInitializing = leavingCandidates.filter((monitorId) => !stillCovered.has(monitorId)); + if (toInitializing.length > 0) { + await this.monitorsRepository.updateByIds(toInitializing, teamId, { status: "initializing" }); + } + } + + return updated; }; } diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index 5ce64238a..ee3615d97 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -11,6 +11,7 @@ const createMaintenanceWindowsRepo = () => create: jest.fn().mockResolvedValue(makeWindow()), findById: jest.fn().mockResolvedValue(makeWindow()), findByMonitorId: jest.fn().mockResolvedValue([makeWindow()]), + findByMonitorIds: jest.fn().mockResolvedValue([]), findByTeamId: jest.fn().mockResolvedValue([makeWindow()]), updateById: jest.fn().mockResolvedValue(makeWindow()), deleteById: jest.fn().mockResolvedValue(makeWindow()), @@ -23,6 +24,7 @@ const createMonitorsRepo = () => { id: "mon-1", teamId: "team-1" }, { id: "mon-2", teamId: "team-1" }, ]), + updateByIds: jest.fn().mockResolvedValue(0), }) as unknown as jest.Mocked; const createService = (overrides?: { @@ -51,6 +53,16 @@ const makeWindow = (overrides?: Partial): MaintenanceWindow = ...overrides, }); +// A window whose start/end straddle "now" — active per isWindowActive +const makeActiveWindow = (overrides?: Partial): MaintenanceWindow => { + const now = Date.now(); + return makeWindow({ + start: new Date(now - 60_000).toISOString(), + end: new Date(now + 60_000).toISOString(), + ...overrides, + }); +}; + const defaultCreateParams = { teamId: "team-1", monitorIDs: ["mon-1", "mon-2"], @@ -302,5 +314,149 @@ describe("MaintenanceWindowService", () => { expect(monitorsRepository.findByIds).not.toHaveBeenCalled(); }); + + // ── transitions ────────────────────────────────────────────────── + + it("does not touch monitor status when window was inactive and stays inactive", async () => { + // default fixture: start/end in 2026-04-10 (past) → inactive + const { service, monitorsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { name: "Updated Name" }, + }); + + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); + }); + + it("sets all monitors to maintenance when window transitions inactive → active", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { start: new Date(Date.now() - 60_000).toISOString() }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "maintenance" }); + }); + + it("sets all monitors to initializing when window transitions active → inactive", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"], active: false })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { active: false }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }); + }); + + it("flips only newly added monitors to maintenance when window stays active", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-2"], "team-1", { status: "maintenance" }); + }); + + it("flips only removed monitors to initializing when window stays active", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1"] }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-2"], "team-1", { status: "initializing" }); + }); + + it("makes no monitor updates when window stays active and monitor set is unchanged", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"], name: "Renamed" })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { name: "Renamed" }, + }); + + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); + }); + + it("does not flip a leaving monitor to initializing if another active window still covers it", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"], active: false })); + (maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([makeActiveWindow({ id: "mw-2", monitorIds: ["mon-2"] })]); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { active: false }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }); + }); + + it("excludes the current window from the overlap check", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"], active: false })); + const { service } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { active: false }, + }); + + expect(maintenanceWindowsRepository.findByMonitorIds).toHaveBeenCalledWith(["mon-1"], "team-1", "mw-1"); + }); + + it("flips a leaving monitor to initializing if other windows covering it are inactive", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.findById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + (maintenanceWindowsRepository.updateById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"], active: false })); + (maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([ + makeWindow({ id: "mw-2", monitorIds: ["mon-1"], active: false }), + ]); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { active: false }, + }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }); + }); }); }); diff --git a/server/test/unit/utils/maintenanceWindow.test.ts b/server/test/unit/utils/maintenanceWindow.test.ts new file mode 100644 index 000000000..5a3a39b6a --- /dev/null +++ b/server/test/unit/utils/maintenanceWindow.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "@jest/globals"; +import { isWindowActive } from "../../../src/utils/maintenanceWindow.ts"; +import type { MaintenanceWindow } from "../../../src/types/index.ts"; + +const makeWindow = (overrides?: Partial): MaintenanceWindow => { + const now = Date.now(); + return { + id: "mw-1", + monitorIds: ["mon-1"], + teamId: "team-1", + active: true, + name: "Scheduled Maintenance", + duration: 60, + durationUnit: "minutes", + repeat: 0, + start: new Date(now - 1000).toISOString(), + end: new Date(now + 1000).toISOString(), + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +}; + +describe("isWindowActive", () => { + it("returns false when window.active is false even if dates span now", () => { + expect(isWindowActive(makeWindow({ active: false }))).toBe(false); + }); + + it("returns true for a one-time window where now is between start and end", () => { + expect(isWindowActive(makeWindow())).toBe(true); + }); + + it("returns false when the one-time window has not started yet", () => { + const now = Date.now(); + expect( + isWindowActive( + makeWindow({ + start: new Date(now + 60_000).toISOString(), + end: new Date(now + 120_000).toISOString(), + }) + ) + ).toBe(false); + }); + + it("returns false when the one-time window has already ended", () => { + const now = Date.now(); + expect( + isWindowActive( + makeWindow({ + start: new Date(now - 120_000).toISOString(), + end: new Date(now - 60_000).toISOString(), + repeat: 0, + }) + ) + ).toBe(false); + }); + + it("returns true when a recurring window's advanced occurrence covers now", () => { + const now = Date.now(); + // 2h ago, 10-min window, hourly repeat — third occurrence (0h) covers now + expect( + isWindowActive( + makeWindow({ + start: new Date(now - 7_200_000).toISOString(), + end: new Date(now - 7_200_000 + 600_000).toISOString(), + repeat: 3_600_000, + }) + ) + ).toBe(true); + }); + + it("returns false when a recurring window's occurrences all miss now", () => { + const now = Date.now(); + // 31min ago, 1-min window, hourly repeat → next occurrence ~29min from now + expect( + isWindowActive( + makeWindow({ + start: new Date(now - 1_860_000).toISOString(), + end: new Date(now - 1_800_000).toISOString(), + repeat: 3_600_000, + }) + ) + ).toBe(false); + }); + + it("respects the explicit `now` parameter for comparisons", () => { + const win = makeWindow({ + start: "2026-01-15T11:00:00Z", + end: "2026-01-15T13:00:00Z", + }); + expect(isWindowActive(win, new Date("2026-01-15T12:00:00Z"))).toBe(true); + expect(isWindowActive(win, new Date("2026-01-15T14:00:00Z"))).toBe(false); + expect(isWindowActive(win, new Date("2026-01-15T10:00:00Z"))).toBe(false); + }); +}); From 0470eab7825e869b41a85a39bb7d6834c3b90578 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 21 May 2026 10:27:46 -0700 Subject: [PATCH 6/8] extract flipping monitor status, flip status on delete --- .../business/maintenanceWindowService.ts | 40 +++++++++++------ .../services/maintenanceWindowService.test.ts | 43 +++++++++++++++++++ 2 files changed, 70 insertions(+), 13 deletions(-) diff --git a/server/src/service/business/maintenanceWindowService.ts b/server/src/service/business/maintenanceWindowService.ts index 541b028f5..5d7a05b7d 100644 --- a/server/src/service/business/maintenanceWindowService.ts +++ b/server/src/service/business/maintenanceWindowService.ts @@ -55,6 +55,24 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { return MaintenanceWindowService.SERVICE_NAME; } + private flipMonitorsLeavingMaintenance = async (monitorIds: string[], teamId: string, excludeWindowId: string, now: Date): Promise => { + if (monitorIds.length === 0) return; + + const otherWindows = await this.maintenanceWindowsRepository.findByMonitorIds(monitorIds, teamId, excludeWindowId); + const stillCovered = new Set(); + for (const otherWindow of otherWindows) { + if (!isWindowActive(otherWindow, now)) continue; + for (const monitorId of otherWindow.monitorIds) { + if (monitorIds.includes(monitorId)) stillCovered.add(monitorId); + } + } + + const toInitializing = monitorIds.filter((monitorId) => !stillCovered.has(monitorId)); + if (toInitializing.length > 0) { + await this.monitorsRepository.updateByIds(toInitializing, teamId, { status: "initializing" }); + } + }; + createMaintenanceWindow = async ({ teamId, monitorIDs, @@ -134,7 +152,14 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { }; deleteMaintenanceWindow = async ({ id, teamId }: { id: string; teamId: string }) => { - return await this.maintenanceWindowsRepository.deleteById(id, teamId); + const deleted = await this.maintenanceWindowsRepository.deleteById(id, teamId); + + const now = new Date(); + if (isWindowActive(deleted, now)) { + await this.flipMonitorsLeavingMaintenance(deleted.monitorIds, teamId, deleted.id, now); + } + + return deleted; }; editMaintenanceWindow = async ({ @@ -180,18 +205,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { } if (leavingCandidates.length > 0) { - const otherWindows = await this.maintenanceWindowsRepository.findByMonitorIds(leavingCandidates, teamId, existing.id); - const stillCovered = new Set(); - for (const otherWindow of otherWindows) { - if (!isWindowActive(otherWindow, now)) continue; - for (const monitorId of otherWindow.monitorIds) { - if (leavingCandidates.includes(monitorId)) stillCovered.add(monitorId); - } - } - const toInitializing = leavingCandidates.filter((monitorId) => !stillCovered.has(monitorId)); - if (toInitializing.length > 0) { - await this.monitorsRepository.updateByIds(toInitializing, teamId, { status: "initializing" }); - } + await this.flipMonitorsLeavingMaintenance(leavingCandidates, teamId, existing.id, now); } return updated; diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index ee3615d97..a51fd5703 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -233,6 +233,49 @@ describe("MaintenanceWindowService", () => { expect(result).toBe(deleted); expect(maintenanceWindowsRepository.deleteById).toHaveBeenCalledWith("mw-1", "team-1"); }); + + it("does not touch monitor status when deleting an inactive window", async () => { + // default fixture has past dates → inactive + const { service, monitorsRepository, maintenanceWindowsRepository } = createService(); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); + expect(maintenanceWindowsRepository.findByMonitorIds).not.toHaveBeenCalled(); + }); + + it("flips covered monitors to initializing when deleting an active window", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }); + }); + + it("excludes the deleted window from the overlap check", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1"] })); + const { service } = createService({ maintenanceWindowsRepository }); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(maintenanceWindowsRepository.findByMonitorIds).toHaveBeenCalledWith(["mon-1"], "team-1", "mw-1"); + }); + + it("does not flip a monitor still covered by another active window", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.deleteById as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + (maintenanceWindowsRepository.findByMonitorIds as jest.Mock).mockResolvedValue([makeActiveWindow({ id: "mw-2", monitorIds: ["mon-2"] })]); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }); + }); }); // ── editMaintenanceWindow ──────────────────────────────────────────────── From fbfb86144724786f3b7ca6ea6edb9521531cc276 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 21 May 2026 10:50:53 -0700 Subject: [PATCH 7/8] flip status on create --- .../business/maintenanceWindowService.ts | 6 +++++- .../services/maintenanceWindowService.test.ts | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/server/src/service/business/maintenanceWindowService.ts b/server/src/service/business/maintenanceWindowService.ts index 5d7a05b7d..d198d1e9d 100644 --- a/server/src/service/business/maintenanceWindowService.ts +++ b/server/src/service/business/maintenanceWindowService.ts @@ -107,7 +107,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { }); } - await this.maintenanceWindowsRepository.create({ + const created = await this.maintenanceWindowsRepository.create({ teamId, monitorIds: monitorIDs, name: name, @@ -118,6 +118,10 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { start: start, end: end, }); + + if (isWindowActive(created)) { + await this.monitorsRepository.updateByIds(created.monitorIds, teamId, { status: "maintenance" }); + } }; getMaintenanceWindowById = async ({ id, teamId }: { id: string; teamId: string }) => { diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index a51fd5703..bee60d4ef 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -149,6 +149,26 @@ describe("MaintenanceWindowService", () => { expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1); expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorIds: ["mon-1"] })); }); + + it("flips monitors to maintenance when the created window is active", async () => { + const maintenanceWindowsRepository = createMaintenanceWindowsRepo(); + (maintenanceWindowsRepository.create as jest.Mock).mockResolvedValue(makeActiveWindow({ monitorIds: ["mon-1", "mon-2"] })); + const { service, monitorsRepository } = createService({ maintenanceWindowsRepository }); + + await service.createMaintenanceWindow(defaultCreateParams); + + expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "maintenance" }); + }); + + it("does not touch monitor status when the created window is inactive", async () => { + // default mock returns makeWindow() with past dates → inactive + const { service, monitorsRepository } = createService(); + + await service.createMaintenanceWindow(defaultCreateParams); + + expect(monitorsRepository.updateByIds).not.toHaveBeenCalled(); + }); }); // ── getMaintenanceWindowById ───────────────────────────────────────────── From 3abacb046b12320c8e6f7f9b3af749b9d205fb60 Mon Sep 17 00:00:00 2001 From: Alex Holliday Date: Thu, 21 May 2026 11:02:59 -0700 Subject: [PATCH 8/8] handle paused status --- .../monitors/IMonitorsRepository.ts | 4 ++-- .../monitors/MongoMonitorsRepository.ts | 10 +++++++--- .../business/maintenanceWindowService.ts | 6 +++--- .../services/maintenanceWindowService.test.ts | 18 +++++++++--------- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/server/src/repositories/monitors/IMonitorsRepository.ts b/server/src/repositories/monitors/IMonitorsRepository.ts index 6e2e9dea1..90fcc5a94 100644 --- a/server/src/repositories/monitors/IMonitorsRepository.ts +++ b/server/src/repositories/monitors/IMonitorsRepository.ts @@ -1,4 +1,4 @@ -import { type MonitorType, type Monitor, type MonitorsSummary, CheckSnapshot } from "@/types/index.js"; +import { type MonitorType, type Monitor, type MonitorStatus, type MonitorsSummary, CheckSnapshot } from "@/types/index.js"; export interface TeamQueryConfig { limit?: number; @@ -31,7 +31,7 @@ export interface IMonitorsRepository { // update updateById(monitorId: string, teamId: string, updates: Partial): Promise; - updateByIds(monitorIds: string[], teamId: string, updates: Partial): Promise; + updateByIds(monitorIds: string[], teamId: string, updates: Partial, excludeStatuses?: MonitorStatus[]): Promise; updateStatusWindowAndChecks( monitorId: string, teamId: string, diff --git a/server/src/repositories/monitors/MongoMonitorsRepository.ts b/server/src/repositories/monitors/MongoMonitorsRepository.ts index 5f6bbac73..3d1f9c3a5 100644 --- a/server/src/repositories/monitors/MongoMonitorsRepository.ts +++ b/server/src/repositories/monitors/MongoMonitorsRepository.ts @@ -1,6 +1,6 @@ import { MonitorModel } from "@/db/models/index.js"; import type { MonitorDocument, CheckSnapshotDocument } from "@/db/models/index.js"; -import type { Monitor, MonitorsSummary, CheckSnapshot } from "@/types/index.js"; +import type { Monitor, MonitorStatus, MonitorsSummary, CheckSnapshot } from "@/types/index.js"; import mongoose, { type FilterQuery, type PipelineStage } from "mongoose"; import type { IMonitorsRepository, TeamQueryConfig, SummaryConfig } from "./IMonitorsRepository.js"; import { MongoBulkWriteError } from "mongodb"; @@ -190,9 +190,13 @@ class MongoMonitorsRepository implements IMonitorsRepository { return this.toEntity(updatedMonitor); }; - updateByIds = async (monitorIds: string[], teamId: string, updates: Partial) => { + updateByIds = async (monitorIds: string[], teamId: string, updates: Partial, excludeStatuses?: MonitorStatus[]) => { const objectIds = monitorIds.map((id) => new mongoose.Types.ObjectId(id)); - const updated = await MonitorModel.updateMany({ _id: { $in: objectIds }, teamId }, { $set: updates }, { runValidators: true }); + const filter: Record = { _id: { $in: objectIds }, teamId }; + if (excludeStatuses && excludeStatuses.length > 0) { + filter.status = { $nin: excludeStatuses }; + } + const updated = await MonitorModel.updateMany(filter, { $set: updates }, { runValidators: true }); return updated.modifiedCount; }; diff --git a/server/src/service/business/maintenanceWindowService.ts b/server/src/service/business/maintenanceWindowService.ts index d198d1e9d..2ea4c4eae 100644 --- a/server/src/service/business/maintenanceWindowService.ts +++ b/server/src/service/business/maintenanceWindowService.ts @@ -69,7 +69,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { const toInitializing = monitorIds.filter((monitorId) => !stillCovered.has(monitorId)); if (toInitializing.length > 0) { - await this.monitorsRepository.updateByIds(toInitializing, teamId, { status: "initializing" }); + await this.monitorsRepository.updateByIds(toInitializing, teamId, { status: "initializing" }, ["paused"]); } }; @@ -120,7 +120,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { }); if (isWindowActive(created)) { - await this.monitorsRepository.updateByIds(created.monitorIds, teamId, { status: "maintenance" }); + await this.monitorsRepository.updateByIds(created.monitorIds, teamId, { status: "maintenance" }, ["paused"]); } }; @@ -205,7 +205,7 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { const leavingCandidates = wasActive ? existing.monitorIds.filter((monitorId) => !isActive || !updated.monitorIds.includes(monitorId)) : []; if (enteringMaintenance.length > 0) { - await this.monitorsRepository.updateByIds(enteringMaintenance, teamId, { status: "maintenance" }); + await this.monitorsRepository.updateByIds(enteringMaintenance, teamId, { status: "maintenance" }, ["paused"]); } if (leavingCandidates.length > 0) { diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index bee60d4ef..12b46de95 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -158,7 +158,7 @@ describe("MaintenanceWindowService", () => { await service.createMaintenanceWindow(defaultCreateParams); expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); - expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "maintenance" }); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "maintenance" }, ["paused"]); }); it("does not touch monitor status when the created window is inactive", async () => { @@ -272,7 +272,7 @@ describe("MaintenanceWindowService", () => { await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); - expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }, ["paused"]); }); it("excludes the deleted window from the overlap check", async () => { @@ -294,7 +294,7 @@ describe("MaintenanceWindowService", () => { await service.deleteMaintenanceWindow({ id: "mw-1", teamId: "team-1" }); expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); - expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }, ["paused"]); }); }); @@ -406,7 +406,7 @@ describe("MaintenanceWindowService", () => { }); expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); - expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "maintenance" }); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "maintenance" }, ["paused"]); }); it("sets all monitors to initializing when window transitions active → inactive", async () => { @@ -422,7 +422,7 @@ describe("MaintenanceWindowService", () => { }); expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); - expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1", "mon-2"], "team-1", { status: "initializing" }, ["paused"]); }); it("flips only newly added monitors to maintenance when window stays active", async () => { @@ -438,7 +438,7 @@ describe("MaintenanceWindowService", () => { }); expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); - expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-2"], "team-1", { status: "maintenance" }); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-2"], "team-1", { status: "maintenance" }, ["paused"]); }); it("flips only removed monitors to initializing when window stays active", async () => { @@ -454,7 +454,7 @@ describe("MaintenanceWindowService", () => { }); expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); - expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-2"], "team-1", { status: "initializing" }); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-2"], "team-1", { status: "initializing" }, ["paused"]); }); it("makes no monitor updates when window stays active and monitor set is unchanged", async () => { @@ -486,7 +486,7 @@ describe("MaintenanceWindowService", () => { }); expect(monitorsRepository.updateByIds).toHaveBeenCalledTimes(1); - expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }, ["paused"]); }); it("excludes the current window from the overlap check", async () => { @@ -519,7 +519,7 @@ describe("MaintenanceWindowService", () => { body: { active: false }, }); - expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }); + expect(monitorsRepository.updateByIds).toHaveBeenCalledWith(["mon-1"], "team-1", { status: "initializing" }, ["paused"]); }); }); });