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 ────────────────────────────────────────────────