Merge pull request #3649 from bluewave-labs/feat/maintenance-window-groups

feat: maintenance window groups
This commit is contained in:
Alexander Holliday authored and GitHub committed 2026-05-12 11:20:51 -07:00
commit ae5115a38f
10 files changed
+262 -32

No files matched your search

@@ -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<void> {
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<GroupResult>([
{
$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<Document>[] = [];
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;
}
}
+2
View File
@@ -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) => {
+5 -5
View File
@@ -1,8 +1,8 @@
import { Schema, model, type Types } from "mongoose";
import { DurationUnits, type MaintenanceWindow } from "@/types/maintenanceWindow.js";
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
monitorId: Types.ObjectId;
type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorIds" | "teamId" | "start" | "end" | "createdAt" | "updatedAt"> & {
monitorIds: Types.ObjectId[];
teamId: Types.ObjectId;
start: Date;
end: Date;
@@ -17,10 +17,10 @@ interface MaintenanceWindowDocument extends MaintenanceWindowDocumentBase {
const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
{
monitorId: {
type: Schema.Types.ObjectId,
monitorIds: {
type: [Schema.Types.ObjectId],
ref: "Monitor",
immutable: true,
index: true,
},
teamId: {
type: Schema.Types.ObjectId,
@@ -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<MaintenanceWindow[]> => {
const maintenanceWindows = await MaintenanceWindowModel.find({
monitorId: monitorId,
monitorIds: monitorId,
teamId: teamId,
});
return this.mapDocuments(maintenanceWindows);
@@ -27,7 +27,11 @@ export interface IMaintenanceWindowService {
}): Promise<{ maintenanceWindows: MaintenanceWindow[]; maintenanceWindowCount: number }>;
getMaintenanceWindowsByMonitorId(params: { monitorId: string; teamId: string }): Promise<MaintenanceWindow[]>;
deleteMaintenanceWindow(params: { id: string; teamId: string }): Promise<MaintenanceWindow>;
editMaintenanceWindow(params: { id: string; teamId: string; body: Partial<MaintenanceWindow> }): Promise<MaintenanceWindow>;
editMaintenanceWindow(params: {
id: string;
teamId: string;
body: Partial<Omit<MaintenanceWindow, "monitorIds">> & { monitors?: string[] };
}): Promise<MaintenanceWindow>;
}
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<MaintenanceWindow> }) => {
return await this.maintenanceWindowsRepository.updateById(id, teamId, body);
editMaintenanceWindow = async ({
id,
teamId,
body,
}: {
id: string;
teamId: string;
body: Partial<Omit<MaintenanceWindow, "monitorIds">> & { monitors?: string[] };
}) => {
const { monitors, ...rest } = body;
const update: Partial<MaintenanceWindow> = 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);
};
}
+1 -1
View File
@@ -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;
@@ -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(),
})
@@ -8,7 +8,7 @@ const makeMaintenanceWindow = (overrides?: Partial<MaintenanceWindow>): 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);
@@ -37,7 +37,7 @@ const createService = (overrides?: {
const makeWindow = (overrides?: Partial<MaintenanceWindow>): 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();
});
});
});
@@ -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"] })])
);
}
});
});