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

@@ -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"],
});