Files
sencho/backend/src/helpers/notificationSchedule.ts
T
SaelixCode 1d1fba0e33 fix: fail closed on corrupt mute schedules and clean invalid replicas
Empty or whitespace stored schedules no longer act as all-day mutes. Invalid schedules trigger remote DELETE cleanup, and the weekly-window form gains accessibility attributes plus component coverage.
2026-07-21 11:47:56 -04:00

127 lines
4.4 KiB
TypeScript

/**
* Weekly UTC maintenance windows for notification suppression rules.
* days are start days (Date#getUTCDay); start inclusive, end exclusive.
*/
export interface NotificationSchedule {
days: number[];
start_minute: number;
end_minute: number;
tz: 'UTC';
}
export type ParseNotificationScheduleResult =
| { ok: true; schedule: NotificationSchedule }
| { ok: false; error: string };
const SCHEDULE_KEYS = new Set(['days', 'start_minute', 'end_minute', 'tz']);
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Strict write-boundary parser. Accepts days in any order; returns sorted unique days. */
export function parseNotificationSchedule(raw: unknown): ParseNotificationScheduleResult {
if (!isPlainObject(raw)) {
return { ok: false, error: 'schedule must be an object or null' };
}
const keys = Object.keys(raw);
if (keys.length !== 4 || keys.some((k) => !SCHEDULE_KEYS.has(k))) {
return { ok: false, error: 'schedule must have exactly days, start_minute, end_minute, and tz' };
}
if (raw.tz !== 'UTC') {
return { ok: false, error: 'schedule.tz must be UTC' };
}
if (!Array.isArray(raw.days) || raw.days.length === 0) {
return { ok: false, error: 'schedule.days must be a nonempty array' };
}
if (raw.days.some((d) => typeof d !== 'number' || !Number.isInteger(d) || d < 0 || d > 6)) {
return { ok: false, error: 'schedule.days must be integers 0..6' };
}
const days = [...new Set(raw.days as number[])].sort((a, b) => a - b);
if (days.length !== raw.days.length) {
return { ok: false, error: 'schedule.days must not contain duplicates' };
}
if (
typeof raw.start_minute !== 'number'
|| !Number.isInteger(raw.start_minute)
|| raw.start_minute < 0
|| raw.start_minute > 1439
) {
return { ok: false, error: 'schedule.start_minute must be an integer 0..1439' };
}
if (
typeof raw.end_minute !== 'number'
|| !Number.isInteger(raw.end_minute)
|| raw.end_minute < 0
|| raw.end_minute > 1439
) {
return { ok: false, error: 'schedule.end_minute must be an integer 0..1439' };
}
if (raw.start_minute === raw.end_minute) {
return { ok: false, error: 'schedule.start_minute and end_minute must differ' };
}
return {
ok: true,
schedule: {
days,
start_minute: raw.start_minute,
end_minute: raw.end_minute,
tz: 'UTC',
},
};
}
/**
* Parse a DB column value. Only SQL null / undefined → legacy null (always in window).
* Empty string, whitespace, or any non-null corrupt value → invalid (must not suppress).
*/
export function parseStoredNotificationSchedule(
raw: unknown,
): { kind: 'null' } | { kind: 'ok'; schedule: NotificationSchedule } | { kind: 'invalid' } {
if (raw == null) return { kind: 'null' };
if (typeof raw === 'string') {
const trimmed = raw.trim();
if (trimmed === '') return { kind: 'invalid' };
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return { kind: 'invalid' };
}
// JSON null is not SQL NULL; treat as corrupt rather than legacy always-on.
if (parsed == null) return { kind: 'invalid' };
const result = parseNotificationSchedule(parsed);
if (!result.ok) return { kind: 'invalid' };
return { kind: 'ok', schedule: result.schedule };
}
const result = parseNotificationSchedule(raw);
if (!result.ok) return { kind: 'invalid' };
return { kind: 'ok', schedule: result.schedule };
}
/** True when the schedule window covers atMs (UTC). Null schedule is always active. */
export function isScheduleActive(schedule: NotificationSchedule | null, atMs: number): boolean {
if (schedule == null) return true;
const date = new Date(atMs);
const day = date.getUTCDay();
const minute = date.getUTCHours() * 60 + date.getUTCMinutes();
const { days, start_minute: start, end_minute: end } = schedule;
if (start < end) {
return days.includes(day) && minute >= start && minute < end;
}
const prevDay = (day + 6) % 7;
return (days.includes(day) && minute >= start)
|| (days.includes(prevDay) && minute < end);
}
/** Whether a loaded rule may suppress at atMs (filters already matched). */
export function scheduleAllowsSuppression(
schedule: NotificationSchedule | null,
scheduleInvalid: boolean,
atMs: number,
): boolean {
if (scheduleInvalid) return false;
return isScheduleActive(schedule, atMs);
}