fix: check automation usage in all rundowns before allowing delete

This commit is contained in:
Carlos Valente
2025-09-21 20:31:24 +02:00
committed by Alex Christoffer Rasmussen
parent cbd7bce106
commit bcabc50ad1
6 changed files with 178 additions and 75 deletions
@@ -107,16 +107,16 @@ export default function AutomationsList(props: AutomationsListProps) {
</IconButton> </IconButton>
</Panel.InlineElements> </Panel.InlineElements>
</tr> </tr>
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</Fragment> </Fragment>
); );
})} })}
{deleteError && (
<tr>
<td colSpan={5}>
<Panel.Error>{deleteError}</Panel.Error>
</td>
</tr>
)}
</tbody> </tbody>
</Panel.Table> </Panel.Table>
</Panel.Card> </Panel.Card>
@@ -1,6 +1,6 @@
import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation, EntryId } from 'ontime-types'; import { TriggerDTO, TimerLifeCycle, AutomationDTO, Automation, ProjectRundowns } from 'ontime-types';
import { makeRundown } from '../../rundown/__mocks__/rundown.mocks.js'; import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { import {
addTrigger, addTrigger,
@@ -203,10 +203,29 @@ describe('deleteAutomation()', () => {
const automations = getAutomations(); const automations = getAutomations();
expect(Object.keys(automations).length).toEqual(1); expect(Object.keys(automations).length).toEqual(1);
const rundown = makeRundown({}); const projectRundowns: ProjectRundowns = {
const timedEventOrder: EntryId[] = []; 'rundown-1': {
id: 'rundown-1',
await deleteAutomation(rundown, timedEventOrder, Object.keys(automations)[0]); title: 'Rundown 1',
order: ['1'],
flatOrder: ['1'],
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [
{
id: 'trigger-1',
title: 'Trigger 1',
trigger: TimerLifeCycle.onClock,
automationId: 'test-automation',
},
],
}),
},
revision: 1,
},
};
await deleteAutomation(projectRundowns, Object.keys(automations)[0]);
const removed = getAutomations(); const removed = getAutomations();
expect(Object.keys(removed).length).toEqual(0); expect(Object.keys(removed).length).toEqual(0);
}); });
@@ -1,5 +1,7 @@
import { TimerLifeCycle } from 'ontime-types'; import { ProjectRundowns, TimerLifeCycle } from 'ontime-types';
import { makeOntimeEvent, makeRundown } from '../../rundown/__mocks__/rundown.mocks.js';
import { makeOntimeEvent } from '../../rundown/__mocks__/rundown.mocks.js';
import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js'; import { isAutomationUsed, parseTemplateNested, stringToOSCArgs } from '../automation.utils.js';
describe('parseTemplateNested()', () => { describe('parseTemplateNested()', () => {
@@ -250,50 +252,109 @@ describe('test stringToOSCArgs()', () => {
describe('isAutomationUsed()', () => { describe('isAutomationUsed()', () => {
it('returns the first event which uses an automation', () => { it('returns the first event which uses an automation', () => {
const rundown = makeRundown({ const projectRundowns: ProjectRundowns = {
entries: { 'rundown-1': {
'1': makeOntimeEvent({ id: 'rundown-1',
id: '1', title: 'Rundown 1',
triggers: [ order: ['1'],
{ flatOrder: ['1'],
id: 'trigger-1', entries: {
title: 'Trigger 1', '1': makeOntimeEvent({
trigger: TimerLifeCycle.onClock, id: '1',
automationId: 'test-automation', triggers: [
}, {
], id: 'trigger-1',
}), title: 'Trigger 1',
trigger: TimerLifeCycle.onClock,
automationId: 'test-automation',
},
],
}),
},
revision: 1,
}, },
}); };
const timedEventOrder = ['1'];
const automationId = 'test-automation'; const automationId = 'test-automation';
const result = isAutomationUsed(rundown, timedEventOrder, automationId); const result = isAutomationUsed(projectRundowns, automationId);
expect(result).toBe('1'); expect(result).toStrictEqual(['Rundown 1', '1']);
});
it('finds usages in any rundown', () => {
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: 'test-automation',
},
],
}),
},
revision: 1,
},
'rundown-2': {
id: 'rundown-2',
title: 'Rundown 2',
order: ['1'],
flatOrder: ['1'],
entries: {
'1': makeOntimeEvent({
id: '1',
triggers: [
{
id: 'trigger-1',
title: 'Trigger 1',
trigger: TimerLifeCycle.onClock,
automationId: 'in-the-second-rundown',
},
],
}),
},
revision: 1,
},
};
const automationId = 'in-the-second-rundown';
const result = isAutomationUsed(projectRundowns, automationId);
expect(result).toStrictEqual(['Rundown 2', '1']);
}); });
it('returns returns undefined if there are no matches', () => { it('returns returns undefined if there are no matches', () => {
const rundown = makeRundown({ const projectRundowns: ProjectRundowns = {
entries: { 'rundown-1': {
'1': makeOntimeEvent({ id: 'rundown-1',
id: '1', title: 'Rundown 1',
triggers: [ order: ['1'],
{ flatOrder: ['1'],
id: 'trigger-1', entries: {
title: 'Trigger 1', '1': makeOntimeEvent({
trigger: TimerLifeCycle.onClock, id: '1',
automationId: 'test-automation', triggers: [
}, {
], id: 'trigger-1',
}), title: 'Trigger 1',
trigger: TimerLifeCycle.onClock,
automationId: 'test-automation',
},
],
}),
},
revision: 1,
}, },
}); };
const timedEventOrder = ['1'];
const automationId = 'does-not-exist'; const automationId = 'does-not-exist';
const result = isAutomationUsed(rundown, timedEventOrder, automationId); const result = isAutomationUsed(projectRundowns, automationId);
expect(result).toBeUndefined(); expect(result).toBeUndefined();
}); });
}); });
@@ -4,8 +4,7 @@ import { Automation, AutomationSettings, ErrorResponse, Trigger } from 'ontime-t
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { oscServer } from '../../adapters/OscAdapter.js'; import { oscServer } from '../../adapters/OscAdapter.js';
import { getDataProvider } from '../../classes/data-provider/DataProvider.js';
import { getCurrentRundown, getRundownMetadata } from '../rundown/rundown.dao.js';
import * as automationDao from './automation.dao.js'; import * as automationDao from './automation.dao.js';
import * as automationService from './automation.service.js'; import * as automationService from './automation.service.js';
@@ -108,10 +107,8 @@ export async function editAutomation(req: Request, res: Response<Automation | Er
export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) { export async function deleteAutomation(req: Request, res: Response<void | ErrorResponse>) {
try { try {
const rundown = getCurrentRundown(); const projectRundowns = getDataProvider().getProjectRundowns();
const { timedEventOrder } = getRundownMetadata(); await automationDao.deleteAutomation(projectRundowns, req.params.id);
await automationDao.deleteAutomation(rundown, timedEventOrder, req.params.id);
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
@@ -2,9 +2,8 @@ import type {
Automation, Automation,
AutomationDTO, AutomationDTO,
AutomationSettings, AutomationSettings,
EntryId,
NormalisedAutomation, NormalisedAutomation,
Rundown, ProjectRundowns,
Trigger, Trigger,
TriggerDTO, TriggerDTO,
} from 'ontime-types'; } from 'ontime-types';
@@ -136,15 +135,15 @@ export async function editAutomation(id: string, newAutomation: AutomationDTO):
/** /**
* Deletes a automation given its ID * Deletes a automation given its ID
*/ */
export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryId[], id: string): Promise<void> { export async function deleteAutomation(projectRundowns: ProjectRundowns, automationId: string): Promise<void> {
const automations = getAutomations(); const automations = getAutomations();
// ignore request if automation does not exist // ignore request if automation does not exist
if (!Object.hasOwn(automations, id)) { if (!Object.hasOwn(automations, automationId)) {
return; return;
} }
// prevent deleting a automation that is in use in triggers // prevent deleting a automation that is in use in triggers
const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === id); const triggers = getAutomationTriggers().filter((trigger) => trigger.automationId === automationId);
if (triggers.length) { if (triggers.length) {
throw new Error( throw new Error(
`Unable to delete automation used in trigger ${triggers[0].title}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`, `Unable to delete automation used in trigger ${triggers[0].title}${triggers.length > 1 ? ` and ${triggers.length - 1} more` : ''}`,
@@ -152,12 +151,12 @@ export async function deleteAutomation(rundown: Rundown, timedEventOrder: EntryI
} }
// prevent deleting a automation that is in use in events // prevent deleting a automation that is in use in events
const isInUse = isAutomationUsed(rundown, timedEventOrder, id); const isInUse = isAutomationUsed(projectRundowns, automationId);
if (isInUse) { if (isInUse) {
throw new Error(`Unable to delete automation used in event with ID ${isInUse}`); throw new Error(`Unable to delete automation used in rundown: ${isInUse[0]}, in event with ID: ${isInUse[1]}`);
} }
delete automations[id]; delete automations[automationId];
await saveChanges({ automations }); await saveChanges({ automations });
} }
@@ -1,4 +1,13 @@
import { EntryId, FilterRule, isOntimeEvent, MaybeNumber, OntimeAction, ontimeActionKeyValues, Rundown } from 'ontime-types'; import {
EntryId,
FilterRule,
isOntimeEvent,
MaybeNumber,
OntimeAction,
ontimeActionKeyValues,
ProjectRundowns,
RundownEntries,
} from 'ontime-types';
import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils'; import { millisToString, removeLeadingZero, splitWhitespace, getPropertyFromPath } from 'ontime-utils';
import type { OscArgOrArrayInput, OscArgInput } from 'osc-min'; import type { OscArgOrArrayInput, OscArgInput } from 'osc-min';
@@ -198,22 +207,40 @@ export function isBooleanEquals(a: boolean, b: string): boolean {
/** /**
* Checks is an automation is used in a rundown * Checks is an automation is used in a rundown
* TODO(v4): this currently only checks the current rundown, we will need to check all rundowns in the future
*/ */
export function isAutomationUsed( function isAutomationUsedInRundown(
rundown: Rundown, entries: RundownEntries,
timedEventOrder: EntryId[], flatOrder: EntryId[],
automationId: string, automationId: string,
): EntryId | undefined { ): EntryId | undefined {
for (let i = 0; i < timedEventOrder.length; i++) { for (let i = 0; i < flatOrder.length; i++) {
const eventId = timedEventOrder[i]; const eventId = flatOrder[i];
const event = rundown.entries[eventId]; const entry = entries[eventId];
if (isOntimeEvent(event) && event.triggers) {
for (const trigger of event.triggers) { // only ontime events can contain triggers
if (isOntimeEvent(entry) && entry.triggers) {
for (const trigger of entry.triggers) {
if (trigger.automationId === automationId) { if (trigger.automationId === automationId) {
return eventId; return entry.id;
} }
} }
} }
} }
} }
/**
* Checks if an automation is used in any of the project rundowns
*/
export function isAutomationUsed(
projectRundowns: ProjectRundowns,
automationId: string,
): [string, EntryId] | undefined {
for (const rundownId in projectRundowns) {
const rundown = projectRundowns[rundownId];
const usedInEvent = isAutomationUsedInRundown(rundown.entries, rundown.flatOrder, automationId);
if (usedInEvent) {
return [rundown.title, usedInEvent];
}
}
}