fix(automation): delete an automation together with its global triggers

The server refused to delete an automation while any global trigger pointed
at it, so removing one meant finding its triggers in a second list and
deleting each by hand first.

The refusal was protecting against dead data, but a trigger pointing at a
deleted automation is the dead data. Both are now written in a single patch,
so there is no window where one outlives the other and nothing to roll back.

An automation attached to an event is still refused: that reference lives in
the rundown, and removing it is an edit to the show rather than to the
automation settings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf
This commit is contained in:
Claude
2026-09-06 15:27:27 +00:00
parent 419ca5c4ca
commit 149cc0da92
2 changed files with 46 additions and 12 deletions
@@ -225,4 +225,44 @@ describe('deleteAutomation()', () => {
const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0);
});
it('takes the automation global triggers with it, and leaves the others alone', async () => {
const doomed = Object.keys(getAutomations())[0];
const survivor = await addAutomation({ title: 'survivor', filterRule: 'all', filters: [], outputs: [] });
await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId: doomed });
await addTrigger({ title: 'on finish', trigger: TimerLifeCycle.onFinish, automationId: doomed });
await addTrigger({ title: 'keep me', trigger: TimerLifeCycle.onStart, automationId: survivor.id });
await deleteAutomation({}, doomed);
// a trigger pointing at nothing never fires, so it must not outlive its automation
expect(getAutomationTriggers()).toEqual([expect.objectContaining({ title: 'keep me' })]);
expect(Object.keys(getAutomations())).toEqual([survivor.id]);
});
it('refuses an automation attached to an event, and keeps its triggers', async () => {
const automationId = Object.keys(getAutomations())[0];
await addTrigger({ title: 'on start', trigger: TimerLifeCycle.onStart, automationId });
const projectRundowns: ProjectRundowns = {
'rundown-1': {
id: 'rundown-1',
title: 'Rundown 1',
order: ['1'],
flatOrder: ['1'],
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [{ id: 'trigger-1', title: 'Trigger 1', trigger: TimerLifeCycle.onClock, automationId }],
}),
},
revision: 1,
},
};
await expect(deleteAutomation(projectRundowns, automationId)).rejects.toThrow(/used in rundown/);
expect(getAutomationTriggers()).toHaveLength(1);
expect(Object.keys(getAutomations())).toEqual([automationId]);
});
});
@@ -145,24 +145,18 @@ export async function deleteAutomation(projectRundowns: ProjectRundowns, automat
return;
}
// prevent deleting a automation that is in use in triggers
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === automationId);
if (triggers.length) {
const firstTrigger = triggers[0];
const triggerTitle = firstTrigger?.title ?? 'Unknown trigger';
throw new Error(
`Unable to delete automation used in trigger ${triggerTitle}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`,
);
}
// prevent deleting a automation that is in use in events
// prevent deleting a automation that is in use in events, the user has to unlink it there
const isInUse = isAutomationUsed(projectRundowns, automationId);
if (isInUse) {
throw new Error(`Unable to delete automation used in rundown: ${isInUse[0]}, in event with ID: ${isInUse[1]}`);
}
// a global trigger without its automation is dead data, so it goes with it.
// Both are written in a single patch, there is no state where one outlived the other
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId !== automationId);
delete automations[automationId];
await saveChanges({ automations });
await saveChanges({ automations, triggers });
}
/**