mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix: require explicit repair before clearing a corrupt mute schedule
The suppression engine already fails closed on an unreadable stored schedule (scheduleInvalid), but the frontend never surfaced that flag: a corrupt rule looked identical to an ordinary unscheduled one, and opening Edit then clicking Update sent an explicit schedule: null, silently turning the corruption into a valid all-day mute. Add the flag to the rule type, show an Invalid schedule badge on the card, and block saving in the edit form until the operator explicitly touches the weekly window (configures a new one, or toggles it to confirm the clear).
This commit is contained in:
@@ -41,6 +41,7 @@ interface NotificationSuppressionRule {
|
||||
enabled: boolean;
|
||||
expires_at: number | null;
|
||||
schedule: MuteRuleSchedule | null;
|
||||
scheduleInvalid: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
@@ -162,6 +163,11 @@ export function NotificationSuppressionSection({
|
||||
const [formScheduleDays, setFormScheduleDays] = useState<number[]>([]);
|
||||
const [formScheduleStart, setFormScheduleStart] = useState('02:00');
|
||||
const [formScheduleEnd, setFormScheduleEnd] = useState('06:00');
|
||||
// True when editing a rule whose stored schedule could not be read. Its window
|
||||
// must not be silently cleared: the operator has to touch the controls first.
|
||||
const [formScheduleInvalid, setFormScheduleInvalid] = useState(false);
|
||||
const [formScheduleTouched, setFormScheduleTouched] = useState(false);
|
||||
const markScheduleTouched = () => setFormScheduleTouched(true);
|
||||
|
||||
const fetchRules = useCallback(async () => {
|
||||
try {
|
||||
@@ -215,6 +221,8 @@ export function NotificationSuppressionSection({
|
||||
setFormScheduleDays([]);
|
||||
setFormScheduleStart('02:00');
|
||||
setFormScheduleEnd('06:00');
|
||||
setFormScheduleInvalid(false);
|
||||
setFormScheduleTouched(false);
|
||||
setEditingId(null);
|
||||
setShowForm(true);
|
||||
onPrefillConsumed?.();
|
||||
@@ -235,6 +243,8 @@ export function NotificationSuppressionSection({
|
||||
setFormScheduleDays([]);
|
||||
setFormScheduleStart('02:00');
|
||||
setFormScheduleEnd('06:00');
|
||||
setFormScheduleInvalid(false);
|
||||
setFormScheduleTouched(false);
|
||||
setEditingId(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
@@ -263,11 +273,19 @@ export function NotificationSuppressionSection({
|
||||
setFormScheduleStart('02:00');
|
||||
setFormScheduleEnd('06:00');
|
||||
}
|
||||
setFormScheduleInvalid(rule.scheduleInvalid);
|
||||
setFormScheduleTouched(false);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim()) { toast.error('Name is required.'); return; }
|
||||
if (formScheduleInvalid && !formScheduleTouched) {
|
||||
toast.error(
|
||||
"This rule's stored weekly window could not be read. Turn the weekly window on to set a new schedule, or toggle it off then on to confirm clearing it.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const preparedPatterns = patternChipsRef.current?.prepareSave();
|
||||
if (!preparedPatterns?.ok) {
|
||||
toast.error('Fix invalid stack patterns before saving.');
|
||||
@@ -566,11 +584,18 @@ export function NotificationSuppressionSection({
|
||||
<div className="flex items-center gap-2">
|
||||
<TogglePill
|
||||
checked={formScheduleEnabled}
|
||||
onChange={setFormScheduleEnabled}
|
||||
onChange={(v) => { setFormScheduleEnabled(v); markScheduleTouched(); }}
|
||||
id="mute-rule-schedule"
|
||||
/>
|
||||
<Label htmlFor="mute-rule-schedule" className="mb-0">Weekly window (UTC)</Label>
|
||||
</div>
|
||||
{formScheduleInvalid && !formScheduleTouched && (
|
||||
<p className="text-xs text-destructive">
|
||||
Stored weekly window could not be read and was not applied (alerts have been
|
||||
delivering normally). Configure a new window above, or toggle it on then off to
|
||||
confirm clearing it.
|
||||
</p>
|
||||
)}
|
||||
{formScheduleEnabled && (
|
||||
<div className="space-y-2 rounded-md border border-border/60 p-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -591,6 +616,7 @@ export function NotificationSuppressionSection({
|
||||
? prev.filter((d) => d !== day)
|
||||
: [...prev, day].sort((a, b) => a - b),
|
||||
);
|
||||
markScheduleTouched();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
@@ -605,7 +631,7 @@ export function NotificationSuppressionSection({
|
||||
id="mute-schedule-start"
|
||||
type="time"
|
||||
value={formScheduleStart}
|
||||
onChange={(e) => setFormScheduleStart(e.target.value)}
|
||||
onChange={(e) => { setFormScheduleStart(e.target.value); markScheduleTouched(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -614,7 +640,7 @@ export function NotificationSuppressionSection({
|
||||
id="mute-schedule-end"
|
||||
type="time"
|
||||
value={formScheduleEnd}
|
||||
onChange={(e) => setFormScheduleEnd(e.target.value)}
|
||||
onChange={(e) => { setFormScheduleEnd(e.target.value); markScheduleTouched(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -673,6 +699,11 @@ export function NotificationSuppressionSection({
|
||||
{rule.expires_at != null && rule.expires_at <= Date.now() && (
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0 text-muted-foreground">Expired</Badge>
|
||||
)}
|
||||
{rule.scheduleInvalid && (
|
||||
<Badge variant="destructive" className="text-[10px] shrink-0" title="Stored weekly window could not be read; alerts deliver normally until repaired in Edit">
|
||||
Invalid schedule
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<TogglePill checked={rule.enabled} onChange={() => handleToggleEnabled(rule)} className="scale-75" />
|
||||
|
||||
@@ -60,6 +60,24 @@ const scheduledRule = {
|
||||
end_minute: 360,
|
||||
tz: 'UTC',
|
||||
},
|
||||
scheduleInvalid: false,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
};
|
||||
|
||||
const corruptScheduleRule = {
|
||||
id: 43,
|
||||
name: 'Corrupt window',
|
||||
node_id: null,
|
||||
stack_patterns: [],
|
||||
label_ids: null,
|
||||
categories: null,
|
||||
levels: null,
|
||||
applies_to: 'both',
|
||||
enabled: true,
|
||||
expires_at: null,
|
||||
schedule: null,
|
||||
scheduleInvalid: true,
|
||||
created_at: 1,
|
||||
updated_at: 1,
|
||||
};
|
||||
@@ -269,4 +287,85 @@ describe('NotificationSuppressionSection', () => {
|
||||
expect(body.schedule).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a corrupt stored schedule as invalid instead of an ordinary unscheduled rule', async () => {
|
||||
mockListRules([corruptScheduleRule]);
|
||||
render(<NotificationSuppressionSection />);
|
||||
await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument());
|
||||
expect(screen.getByText('Invalid schedule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks saving a corrupt-schedule rule until the operator touches the weekly window', async () => {
|
||||
mockListRules([corruptScheduleRule]);
|
||||
render(<NotificationSuppressionSection />);
|
||||
await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument());
|
||||
expect(screen.getByLabelText(/Weekly window \(UTC\)/i)).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('could not be read'),
|
||||
);
|
||||
});
|
||||
const puts = mockedFetch.mock.calls.filter(
|
||||
([url, opts]) => url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT',
|
||||
);
|
||||
expect(puts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('allows saving a corrupt-schedule rule once the operator explicitly clears it', async () => {
|
||||
mockListRules([corruptScheduleRule]);
|
||||
render(<NotificationSuppressionSection />);
|
||||
await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument());
|
||||
|
||||
// Explicit acknowledgement: turn the window on, then off again, to intentionally clear it.
|
||||
await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i));
|
||||
await userEvent.click(screen.getByLabelText(/Weekly window \(UTC\)/i));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
|
||||
await waitFor(() => {
|
||||
const put = mockedFetch.mock.calls.find(
|
||||
([url, opts]) =>
|
||||
url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT',
|
||||
);
|
||||
expect(put).toBeTruthy();
|
||||
const body = JSON.parse((put![1] as { body: string }).body);
|
||||
expect(body.schedule).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('allows saving a corrupt-schedule rule once the operator configures a new valid schedule', async () => {
|
||||
mockListRules([corruptScheduleRule]);
|
||||
render(<NotificationSuppressionSection />);
|
||||
await waitFor(() => expect(screen.getByText('Corrupt window')).toBeInTheDocument());
|
||||
|
||||
await userEvent.click(screen.getByTitle('Edit'));
|
||||
await waitFor(() => expect(screen.getByRole('dialog', { name: /Edit mute rule/i })).toBeInTheDocument());
|
||||
await enableWeeklyWindow();
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Sat' }));
|
||||
fireEvent.change(screen.getByLabelText('Start (UTC)'), { target: { value: '02:00' } });
|
||||
fireEvent.change(screen.getByLabelText('End (UTC)'), { target: { value: '06:00' } });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create|Update/i }));
|
||||
await waitFor(() => {
|
||||
const put = mockedFetch.mock.calls.find(
|
||||
([url, opts]) =>
|
||||
url === '/notification-suppression-rules/43' && (opts as { method?: string })?.method === 'PUT',
|
||||
);
|
||||
expect(put).toBeTruthy();
|
||||
const body = JSON.parse((put![1] as { body: string }).body);
|
||||
expect(body.schedule).toEqual({
|
||||
days: [6],
|
||||
start_minute: 120,
|
||||
end_minute: 360,
|
||||
tz: 'UTC',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user