diff --git a/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts b/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts new file mode 100644 index 000000000..a60b8786a --- /dev/null +++ b/server/src/db/migration/0007_migrateMaintenanceWindowMonitorIdToArray.ts @@ -0,0 +1,111 @@ +import mongoose from "mongoose"; +import type { AnyBulkWriteOperation, Document } from "mongodb"; +import { logger } from "@/utils/logger.js"; +import MaintenanceWindowModel from "../models/MaintenanceWindow.js"; + +type GroupResult = { + _id: { + teamId: mongoose.Types.ObjectId; + name: string; + start: Date; + end: Date; + repeat: number; + durationUnit: string; + duration: number; + active: boolean; + }; + docIds: mongoose.Types.ObjectId[]; + monitorIds: mongoose.Types.ObjectId[]; +}; + +export async function migrateMaintenanceWindowMonitorIdToArray(): Promise { + const SERVICE_NAME = "Migration:MigrateMaintenanceWindowMonitorIdToArray"; + + try { + logger.info({ + service: SERVICE_NAME, + message: "Starting migration of MaintenanceWindow.monitorId to monitorIds array (eager merge)", + }); + + const db = mongoose.connection.db; + if (!db) { + throw new Error("Database connection is not initialized"); + } + + // Group old-style docs by their scheduling key. + // Skip rows that already have a monitorIds array — those are either already migrated + // or in a partially-migrated state that needs manual review. + const groups: GroupResult[] = await MaintenanceWindowModel.aggregate([ + { + $match: { + monitorId: { $exists: true, $ne: null }, + monitorIds: { $exists: false }, + }, + }, + { + $group: { + _id: { + teamId: "$teamId", + name: "$name", + start: "$start", + end: "$end", + repeat: "$repeat", + durationUnit: "$durationUnit", + duration: "$duration", + active: "$active", + }, + docIds: { $push: "$_id" }, + monitorIds: { $addToSet: "$monitorId" }, + }, + }, + ]); + + if (groups.length === 0) { + logger.info({ service: SERVICE_NAME, message: "No MaintenanceWindow documents needed migration" }); + return; + } + + const bulkOps: AnyBulkWriteOperation[] = []; + let originalDocCount = 0; + + for (const group of groups) { + const { docIds, monitorIds } = group; + originalDocCount += docIds.length; + const [survivorId, ...duplicateIds] = docIds; + + bulkOps.push({ + updateOne: { + filter: { _id: survivorId }, + update: { + $set: { monitorIds }, + $unset: { monitorId: "" }, + }, + }, + }); + + if (duplicateIds.length > 0) { + bulkOps.push({ + deleteMany: { + filter: { _id: { $in: duplicateIds } }, + }, + }); + } + } + + const result = await MaintenanceWindowModel.collection.bulkWrite(bulkOps, { ordered: true }); + const deletedCount = originalDocCount - groups.length; + + logger.info({ + service: SERVICE_NAME, + message: `Migration complete. Consolidated ${originalDocCount} old document(s) into ${groups.length} group window(s); deleted ${deletedCount} duplicate(s).`, + }); + logger.debug({ + service: SERVICE_NAME, + message: `bulkWrite result: modified=${result.modifiedCount}, deleted=${result.deletedCount}`, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error({ service: SERVICE_NAME, message: `Error during MaintenanceWindow migration: ${errorMessage}` }); + throw error; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 5b74a96fc..61785118e 100644 --- a/server/src/db/migration/index.ts +++ b/server/src/db/migration/index.ts @@ -5,6 +5,7 @@ import { fixInfrastructureThresholds } from "./0004_fixInfrastructureThresholds. import MigrationModel from "../models/Migration.js"; import { migrateStatusPageTypeToArray } from "./0005_migrateStatusPageTypeToArray.js"; import { cleanupDuplicateMonitorStatsForUniqueIndex } from "./0006_cleanupDuplicateMonitorStatsForUniqueIndex.js"; +import { migrateMaintenanceWindowMonitorIdToArray } from "./0007_migrateMaintenanceWindowMonitorIdToArray.js"; import type { ILogger } from "@/utils/logger.js"; type MigrationEntry = { @@ -19,6 +20,7 @@ const migrations: MigrationEntry[] = [ { name: "0004_fixInfrastructureThresholds", execute: fixInfrastructureThresholds }, { name: "0005_migrateStatusPageTypeToArray", execute: migrateStatusPageTypeToArray }, { name: "0006_cleanupDuplicateMonitorStatsForUniqueIndex", execute: cleanupDuplicateMonitorStatsForUniqueIndex }, + { name: "0007_migrateMaintenanceWindowMonitorIdToArray", execute: migrateMaintenanceWindowMonitorIdToArray }, ]; const runMigrations = async (logger?: ILogger) => { diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index 703c144c8..654ae50dd 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -1,8 +1,8 @@ import { Schema, model, type Types } from "mongoose"; import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js"; -type MaintenanceWindowDocumentBase = Omit & { - monitorId: Types.ObjectId; +type MaintenanceWindowDocumentBase = Omit & { + monitorIds: Types.ObjectId[]; teamId: Types.ObjectId; start: Date; end: Date; @@ -17,10 +17,10 @@ interface MaintenanceWindowDocument extends MaintenanceWindowDocumentBase { const MaintenanceWindowSchema = new Schema( { - monitorId: { - type: Schema.Types.ObjectId, + monitorIds: { + type: [Schema.Types.ObjectId], ref: "Monitor", - immutable: true, + index: true, }, teamId: { type: Schema.Types.ObjectId, diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts index ad96328c5..635ddc2de 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts @@ -29,7 +29,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository private toEntity = (doc: MaintenanceWindowDocument): MaintenanceWindow => { return { id: this.toStringId(doc._id), - monitorId: this.toStringId(doc.monitorId), + monitorIds: doc.monitorIds.map(this.toStringId), teamId: this.toStringId(doc.teamId), active: doc.active, name: doc.name, @@ -67,7 +67,7 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository findByMonitorId = async (monitorId: string, teamId: string): Promise => { const maintenanceWindows = await MaintenanceWindowModel.find({ - monitorId: monitorId, + monitorIds: monitorId, teamId: teamId, }); return this.mapDocuments(maintenanceWindows); diff --git a/server/src/service/business/maintenanceWindowService.ts b/server/src/service/business/maintenanceWindowService.ts index 50278d215..e56ad3757 100644 --- a/server/src/service/business/maintenanceWindowService.ts +++ b/server/src/service/business/maintenanceWindowService.ts @@ -27,7 +27,11 @@ export interface IMaintenanceWindowService { }): Promise<{ maintenanceWindows: MaintenanceWindow[]; maintenanceWindowCount: number }>; getMaintenanceWindowsByMonitorId(params: { monitorId: string; teamId: string }): Promise; deleteMaintenanceWindow(params: { id: string; teamId: string }): Promise; - editMaintenanceWindow(params: { id: string; teamId: string; body: Partial }): Promise; + editMaintenanceWindow(params: { + id: string; + teamId: string; + body: Partial> & { monitors?: string[] }; + }): Promise; } export class MaintenanceWindowService implements IMaintenanceWindowService { @@ -84,20 +88,17 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { }); } - const dbTransactions = monitorIDs.map((monitorId: string) => { - return this.maintenanceWindowsRepository.create({ - teamId, - monitorId, - name: name, - active: active, - duration: duration, - durationUnit: durationUnit, - repeat: repeat, - start: start, - end: end, - }); + await this.maintenanceWindowsRepository.create({ + teamId, + monitorIds: monitorIDs, + name: name, + active: active, + duration: duration, + durationUnit: durationUnit, + repeat: repeat, + start: start, + end: end, }); - await Promise.all(dbTransactions); }; getMaintenanceWindowById = async ({ id, teamId }: { id: string; teamId: string }) => { @@ -135,7 +136,32 @@ export class MaintenanceWindowService implements IMaintenanceWindowService { return await this.maintenanceWindowsRepository.deleteById(id, teamId); }; - editMaintenanceWindow = async ({ id, teamId, body }: { id: string; teamId: string; body: Partial }) => { - return await this.maintenanceWindowsRepository.updateById(id, teamId, body); + editMaintenanceWindow = async ({ + id, + teamId, + body, + }: { + id: string; + teamId: string; + body: Partial> & { monitors?: string[] }; + }) => { + const { monitors, ...rest } = body; + const update: Partial = rest; + + if (monitors !== undefined) { + const monitorDocs = await this.monitorsRepository.findByIds(monitors); + const unauthorizedMonitors = monitorDocs.filter((monitor) => monitor.teamId !== teamId); + if (unauthorizedMonitors.length > 0) { + throw new AppError({ + message: "Unauthorized to edit maintenance window for one or more monitors", + service: SERVICE_NAME, + method: "editMaintenanceWindow", + status: 403, + }); + } + update.monitorIds = monitors; + } + + return await this.maintenanceWindowsRepository.updateById(id, teamId, update); }; } diff --git a/server/src/types/maintenanceWindow.ts b/server/src/types/maintenanceWindow.ts index 950dc1713..4fc446dda 100644 --- a/server/src/types/maintenanceWindow.ts +++ b/server/src/types/maintenanceWindow.ts @@ -3,7 +3,7 @@ export type DurationUnit = (typeof DurationUnits)[number]; export interface MaintenanceWindow { id: string; - monitorId: string; + monitorIds: string[]; teamId: string; active: boolean; name: string; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index c53e39b20..837db28e8 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -72,7 +72,7 @@ export const editMaintenanceByIdWindowBodyValidation = z start: dateToString.optional(), end: dateToString.optional(), expiry: dateToString.optional(), - monitors: z.array(z.string()).optional(), + monitors: z.array(z.string()).min(1, "At least one monitor is required").optional(), duration: z.number().optional(), durationUnit: z.enum(DurationUnits).optional(), }) diff --git a/server/test/integration/heartbeatJobMaintenance.test.ts b/server/test/integration/heartbeatJobMaintenance.test.ts index 663c2e61e..5df287242 100644 --- a/server/test/integration/heartbeatJobMaintenance.test.ts +++ b/server/test/integration/heartbeatJobMaintenance.test.ts @@ -8,7 +8,7 @@ const makeMaintenanceWindow = (overrides?: Partial): Maintena const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now return { id: "mw-1", - monitorId: "mon-1", + monitorIds: ["mon-1"], teamId: "team-1", active: true, name: "Test Maintenance", @@ -109,6 +109,23 @@ describe("Heartbeat job: maintenance windows", () => { expect(h.bufferStub.addToBuffer).toHaveBeenCalledTimes(1); }); + it("skips checks for every monitor covered by a group window", async () => { + const monitorA = makeMonitor({ id: "mon-a", name: "Monitor A" }); + const monitorB = makeMonitor({ id: "mon-b", name: "Monitor B" }); + h.monitorsRepo.seed(monitorA); + h.monitorsRepo.seed(monitorB); + + // A single group window covers both monitors + h.maintenanceWindowsRepo.findByMonitorId.mockResolvedValue([makeMaintenanceWindow({ monitorIds: ["mon-a", "mon-b"] })]); + + await h.heartbeatJob(monitorA); + await h.heartbeatJob(monitorB); + + expect((await h.monitorsRepo.findById("mon-a", "team-1")).status).toBe("maintenance"); + expect((await h.monitorsRepo.findById("mon-b", "team-1")).status).toBe("maintenance"); + expect(h.networkService.requestStatus).not.toHaveBeenCalled(); + }); + it("skips checks when window is in the past (expired)", async () => { const monitor = makeMonitor(); h.monitorsRepo.seed(monitor); diff --git a/server/test/unit/services/maintenanceWindowService.test.ts b/server/test/unit/services/maintenanceWindowService.test.ts index 302ff128c..5ce64238a 100644 --- a/server/test/unit/services/maintenanceWindowService.test.ts +++ b/server/test/unit/services/maintenanceWindowService.test.ts @@ -37,7 +37,7 @@ const createService = (overrides?: { const makeWindow = (overrides?: Partial): MaintenanceWindow => ({ id: "mw-1", - monitorId: "mon-1", + monitorIds: ["mon-1"], teamId: "team-1", active: true, name: "Scheduled Maintenance", @@ -80,16 +80,16 @@ describe("MaintenanceWindowService", () => { // ── createMaintenanceWindow ────────────────────────────────────────────── describe("createMaintenanceWindow", () => { - it("creates a maintenance window for each monitor", async () => { + it("creates a single maintenance window covering all monitor IDs", async () => { const { service, maintenanceWindowsRepository } = createService(); await service.createMaintenanceWindow(defaultCreateParams); - expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(2); + expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1); expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith( expect.objectContaining({ teamId: "team-1", - monitorId: "mon-1", + monitorIds: ["mon-1", "mon-2"], name: "Scheduled Maintenance", active: true, duration: 60, @@ -99,7 +99,6 @@ describe("MaintenanceWindowService", () => { end: "2026-04-10T03:00:00Z", }) ); - expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorId: "mon-2" })); }); it("verifies monitor ownership via findByIds", async () => { @@ -130,12 +129,13 @@ describe("MaintenanceWindowService", () => { await expect(service.createMaintenanceWindow(defaultCreateParams)).resolves.toBeUndefined(); }); - it("creates a single window when monitorIDs has one entry", async () => { + it("passes a single-element monitorIds array when only one monitor is provided", async () => { const { service, maintenanceWindowsRepository } = createService(); await service.createMaintenanceWindow({ ...defaultCreateParams, monitorIDs: ["mon-1"] }); expect(maintenanceWindowsRepository.create).toHaveBeenCalledTimes(1); + expect(maintenanceWindowsRepository.create).toHaveBeenCalledWith(expect.objectContaining({ monitorIds: ["mon-1"] })); }); }); @@ -248,5 +248,59 @@ describe("MaintenanceWindowService", () => { expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { active: false, repeat: 3600000 }); }); + + it("maps the monitors body field to monitorIds when calling the repository", async () => { + const { service, maintenanceWindowsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }); + + expect(maintenanceWindowsRepository.updateById).toHaveBeenCalledWith("mw-1", "team-1", { monitorIds: ["mon-1", "mon-2"] }); + }); + + it("verifies monitor ownership when monitors is in the body", async () => { + const { service, monitorsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }); + + expect(monitorsRepository.findByIds).toHaveBeenCalledWith(["mon-1", "mon-2"]); + }); + + it("throws 403 when a new monitor belongs to a different team", async () => { + const monitorsRepository = createMonitorsRepo(); + (monitorsRepository.findByIds as jest.Mock).mockResolvedValue([ + { id: "mon-1", teamId: "team-1" }, + { id: "mon-2", teamId: "team-other" }, + ]); + const { service, maintenanceWindowsRepository } = createService({ monitorsRepository }); + + await expect( + service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { monitors: ["mon-1", "mon-2"] }, + }) + ).rejects.toThrow("Unauthorized to edit maintenance window for one or more monitors"); + expect(maintenanceWindowsRepository.updateById).not.toHaveBeenCalled(); + }); + + it("does not call findByIds when monitors is not in the body", async () => { + const { service, monitorsRepository } = createService(); + + await service.editMaintenanceWindow({ + id: "mw-1", + teamId: "team-1", + body: { name: "Updated Name" }, + }); + + expect(monitorsRepository.findByIds).not.toHaveBeenCalled(); + }); }); }); diff --git a/server/test/unit/validation/maintenanceWindowValidation.test.ts b/server/test/unit/validation/maintenanceWindowValidation.test.ts index dfede3670..a209ba64f 100644 --- a/server/test/unit/validation/maintenanceWindowValidation.test.ts +++ b/server/test/unit/validation/maintenanceWindowValidation.test.ts @@ -168,4 +168,24 @@ describe("editMaintenanceByIdWindowBodyValidation", () => { }); expect(result.success).toBe(true); }); + + it("accepts a body without a monitors field (monitors is optional on edit)", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ name: "Updated Name" }); + expect(result.success).toBe(true); + }); + + it("accepts a body with one or more monitors", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ monitors: ["mon-1"] }); + expect(result.success).toBe(true); + }); + + it("rejects an empty monitors array", () => { + const result = editMaintenanceByIdWindowBodyValidation.safeParse({ monitors: [] }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ message: "At least one monitor is required", path: ["monitors"] })]) + ); + } + }); });