mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-27 20:29:10 +00:00
1d1fba0e33
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.
127 lines
4.4 KiB
TypeScript
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);
|
|
}
|