From 149cc0da925ff8a793772a3c5990f64308422e41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:27:27 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AfDKsy6PE3Rbyt32Fg4YKf --- .../__tests__/automation.dao.test.ts | 40 +++++++++++++++++++ .../src/api-data/automation/automation.dao.ts | 18 +++------ 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts b/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts index ed5464a5d..b83c30e64 100644 --- a/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts +++ b/apps/server/src/api-data/automation/__tests__/automation.dao.test.ts @@ -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]); + }); }); diff --git a/apps/server/src/api-data/automation/automation.dao.ts b/apps/server/src/api-data/automation/automation.dao.ts index 464df87e4..bb849b73a 100644 --- a/apps/server/src/api-data/automation/automation.dao.ts +++ b/apps/server/src/api-data/automation/automation.dao.ts @@ -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 }); } /**