diff --git a/client/src/Pages/Maintenance/create/index.tsx b/client/src/Pages/Maintenance/create/index.tsx index d130ee7c7..44a4b9a81 100644 --- a/client/src/Pages/Maintenance/create/index.tsx +++ b/client/src/Pages/Maintenance/create/index.tsx @@ -85,10 +85,6 @@ const CreateMaintenanceWindowPage = () => { repeat, }; - if (repeat === 0) { - payload.expiry = endDateTime.toISOString(); - } - return payload; }; diff --git a/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts new file mode 100644 index 000000000..850ab19ef --- /dev/null +++ b/server/src/db/migration/0007_dropMaintenanceWindowExpiryIndex.ts @@ -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 { + 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; + } +} diff --git a/server/src/db/migration/index.ts b/server/src/db/migration/index.ts index 5b74a96fc..fcf0cf1fb 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 { 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) => { diff --git a/server/src/db/models/MaintenanceWindow.ts b/server/src/db/models/MaintenanceWindow.ts index 311cf97bf..e3287ce7c 100644 --- a/server/src/db/models/MaintenanceWindow.ts +++ b/server/src/db/models/MaintenanceWindow.ts @@ -6,7 +6,6 @@ type MaintenanceWindowDocumentBase = Omit( end: { type: Date, }, - expiry: { - type: Date, - index: { expires: "0s" }, - }, }, { timestamps: true, diff --git a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts index ad96328c5..0cdda805e 100644 --- a/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts +++ b/server/src/repositories/maintenance-windows/MongoMaintenanceWindowsRepository.ts @@ -45,11 +45,6 @@ class MongoMaintenanceWindowsRepository implements IMaintenanceWindowsRepository create = async (data: Partial): Promise => { 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); }; diff --git a/server/src/validation/maintenanceWindowValidation.ts b/server/src/validation/maintenanceWindowValidation.ts index bee06e7bc..5d83b3fed 100644 --- a/server/src/validation/maintenanceWindowValidation.ts +++ b/server/src/validation/maintenanceWindowValidation.ts @@ -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"], + });