Merge pull request #3649 from bluewave-labs/feat/maintenance-window-groups
feat: maintenance window groups
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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"] })])
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user