fix(maintenance): stop silent deletion of one-time maintenance windows

Maintenance windows could disappear seconds after creation when the API received
a payload with end dates already in the past. The repository auto-set
expiry = end for one-time windows (repeat = 0), and Mongo's TTL index on the
expiry field then deleted the row on its next sweep. This was particularly
visible with automation tools sending payloads in non-UTC timezones.

## Changes

### Validator hardening
- Add .strict() to create + edit body schemas so unknown keys return 400
  instead of being silently dropped.
- Refine end > start on create.
- Refine end > now for one-time create payloads (repeat = 0).
- Refine end > start on edit when both are provided.
- Remove the accepted expiry field from both schemas.

### Stop the auto-delete behaviour
- Repository.create no longer copies end into expiry.
- Schema's expiry field + TTL index removed.
- Migration 0007 drops the live TTL index and unsets stale expiry values on
  existing rows so legacy data is no longer at risk.

### Client
- Form submission no longer includes expiry.
This commit is contained in:
gorkem-bwl committed 2026-05-07 12:29:26 -04:00
1 parent a69f8a5ab5
commit 4db95cfa44
6 files changed
+86 -36

No files matched your search

@@ -85,10 +85,6 @@ const CreateMaintenanceWindowPage = () => {
repeat,
};
if (repeat === 0) {
payload.expiry = endDateTime.toISOString();
}
return payload;
};
@@ -0,0 +1,48 @@
import { MaintenanceWindowModel } from "../models/MaintenanceWindow.js";
import { logger } from "@/utils/logger.js";
/**
* Drops the TTL index on MaintenanceWindow.expiry and unsets the expiry field.
*
* Earlier versions auto-set expiry = end for one-time maintenance windows. Combined with the
* TTL index ({ expires: "0s" }), this caused Mongo to silently delete completed one-time
* windows shortly after their end-date passed — including windows whose end-date was already
* in the past at creation time (e.g. timezone-mismatched payloads from automation tools).
* The fix stops writing expiry on create, and this migration removes the index and stale field
* so legacy rows are no longer at risk of being deleted by the TTL monitor.
*/
export async function dropMaintenanceWindowExpiryIndex(): Promise<void> {
const SERVICE_NAME = "Migration:DropMaintenanceWindowExpiryIndex";
try {
const collection = MaintenanceWindowModel.collection;
const indexes = await collection.indexes();
const expiryIndex = indexes.find((idx) => idx.key && idx.key.expiry === 1);
if (expiryIndex?.name) {
await collection.dropIndex(expiryIndex.name);
logger.info({
service: SERVICE_NAME,
message: `Dropped TTL index ${expiryIndex.name} on MaintenanceWindow.expiry`,
});
} else {
logger.info({
service: SERVICE_NAME,
message: "No TTL index found on MaintenanceWindow.expiry",
});
}
const result = await collection.updateMany({ expiry: { $exists: true } }, { $unset: { expiry: "" } });
logger.info({
service: SERVICE_NAME,
message: `Unset expiry on ${result.modifiedCount} maintenance windows`,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error({
service: SERVICE_NAME,
message: `Error dropping expiry index: ${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 { dropMaintenanceWindowExpiryIndex } from "./0007_dropMaintenanceWindowExpiryIndex.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_dropMaintenanceWindowExpiryIndex", execute: dropMaintenanceWindowExpiryIndex },
];
const runMigrations = async (logger?: ILogger) => {
@@ -6,7 +6,6 @@ type MaintenanceWindowDocumentBase = Omit<MaintenanceWindow, "id" | "monitorId"
teamId: Types.ObjectId;
start: Date;
end: Date;
expiry?: Date;
createdAt: Date;
updatedAt: Date;
};
@@ -50,10 +49,6 @@ const MaintenanceWindowSchema = new Schema<MaintenanceWindowDocument>(
end: {
type: Date,
},
expiry: {
type: Date,
index: { expires: "0s" },
},
},
{
timestamps: true,
@@ -45,11 +45,6 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository
create = async (data: Partial<MaintenanceWindow>): Promise<MaintenanceWindow> => {
const maintenanceWindow = new MaintenanceWindowModel(data);
// If the maintenance window is a one time window, set the expiry to the end date
if (maintenanceWindow.repeat === 0) {
maintenanceWindow.expiry = maintenanceWindow.end;
}
const result = await maintenanceWindow.save();
return this.toEntity(result);
};
@@ -7,17 +7,26 @@ const dateToString = z.coerce.date().transform((d) => d.toISOString());
// Maintenance Window Validations
//****************************************
export const createMaintenanceWindowBodyValidation = z.object({
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
name: z.string().min(1, "Name is required"),
active: z.boolean().optional(),
duration: z.number().min(1, "Duration is required"),
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]),
start: dateToString,
end: dateToString,
repeat: z.number().min(0, "Repeat must be a non-negative number"),
expiry: dateToString.optional(),
});
export const createMaintenanceWindowBodyValidation = z
.object({
monitors: z.array(z.string()).min(1, "At least one monitor is required"),
name: z.string().min(1, "Name is required"),
active: z.boolean().optional(),
duration: z.number().min(1, "Duration is required"),
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]),
start: dateToString,
end: dateToString,
repeat: z.number().min(0, "Repeat must be a non-negative number"),
})
.strict()
.refine((data) => new Date(data.end).getTime() > new Date(data.start).getTime(), {
message: "End must be after start",
path: ["end"],
})
.refine((data) => data.repeat > 0 || new Date(data.end).getTime() > Date.now(), {
message: "End must be in the future for one-time maintenance windows",
path: ["end"],
});
export const getMaintenanceWindowByIdParamValidation = z.object({
id: z.string().min(1, "ID is required"),
@@ -43,14 +52,19 @@ export const editMaintenanceWindowByIdParamValidation = z.object({
id: z.string().min(1, "ID is required"),
});
export const editMaintenanceByIdWindowBodyValidation = z.object({
active: z.boolean().optional(),
name: z.string().optional(),
repeat: z.number().optional(),
start: dateToString.optional(),
end: dateToString.optional(),
expiry: dateToString.optional(),
monitors: z.array(z.string()).optional(),
duration: z.number().optional(),
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(),
});
export const editMaintenanceByIdWindowBodyValidation = z
.object({
active: z.boolean().optional(),
name: z.string().optional(),
repeat: z.number().optional(),
start: dateToString.optional(),
end: dateToString.optional(),
monitors: z.array(z.string()).optional(),
duration: z.number().optional(),
durationUnit: z.enum(["seconds", "minutes", "hours", "days"]).optional(),
})
.strict()
.refine((data) => !data.start || !data.end || new Date(data.end).getTime() > new Date(data.start).getTime(), {
message: "End must be after start",
path: ["end"],
});