feat(automation): create global triggers with automations

This commit is contained in:
Carlos Valente
2026-09-11 19:56:42 +02:00
parent 123692827c
commit 4601770d1d
8 changed files with 203 additions and 25 deletions
+6 -2
View File
@@ -4,6 +4,7 @@ import type {
AutomationDTO,
AutomationOutput,
AutomationSettings,
AutomationTriggerDTO,
Trigger,
TriggerDTO,
} from 'ontime-types';
@@ -57,8 +58,11 @@ export function deleteTrigger(id: string): Promise<void> {
/**
* HTTP request to create a new automation
*/
export async function addAutomation(automation: AutomationDTO): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, automation);
export async function addAutomation(
automation: AutomationDTO,
triggers: AutomationTriggerDTO[] = [],
): Promise<Automation> {
const res = await axios.post(`${automationsPath}/automation`, { ...automation, triggers });
return res.data;
}
@@ -135,6 +135,27 @@ describe('addAutomation()', () => {
const automations = getAutomations();
expect(automations[automation.id]).toMatchObject(testData);
});
it('creates an automation and its global triggers together', async () => {
const automation = await addAutomation({ title: 'triggered', filterRule: 'all', filters: [], outputs: [] }, [
{ title: 'triggered — On Start', trigger: TimerLifeCycle.onStart },
{ title: 'triggered — On Finish', trigger: TimerLifeCycle.onFinish },
]);
expect(getAutomations()[automation.id]).toEqual(automation);
expect(getAutomationTriggers()).toEqual([
expect.objectContaining({
title: 'triggered — On Start',
trigger: TimerLifeCycle.onStart,
automationId: automation.id,
}),
expect.objectContaining({
title: 'triggered — On Finish',
trigger: TimerLifeCycle.onFinish,
automationId: automation.id,
}),
]);
});
});
describe('editAutomation()', () => {
@@ -225,4 +246,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]);
});
});
@@ -1,4 +1,17 @@
import { parseOutput } from '../automation.validation.js';
import { parseAutomationTriggers, parseOutput } from '../automation.validation.js';
describe('parseAutomationTriggers', () => {
it('accepts trigger descriptors without an automation ID', () => {
expect(parseAutomationTriggers([{ title: 'Start', trigger: 'onStart' }])).toEqual([
{ title: 'Start', trigger: 'onStart' },
]);
});
it('rejects incomplete or unknown trigger descriptors', () => {
expect(() => parseAutomationTriggers([{ trigger: 'onStart' }])).toThrow();
expect(() => parseAutomationTriggers([{ title: 'Start', trigger: 'unknown' }])).toThrow();
});
});
describe('parseOutput', () => {
describe('handles OSC outputs', () => {
@@ -24,6 +37,30 @@ describe('parseOutput', () => {
};
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
});
it('rejects invalid targets and ports', () => {
expect(() =>
parseOutput({ type: 'osc', targetIP: 'not a host', targetPort: 53000, address: '/test', args: '' }),
).toThrow('Invalid OSC target');
expect(() =>
parseOutput({ type: 'osc', targetIP: '127.0.0.1', targetPort: 70000, address: '/test', args: '' }),
).toThrow('Invalid OSC port');
expect(() =>
parseOutput({ type: 'osc', targetIP: '127.0.0.1', targetPort: 0, address: '/test', args: '' }),
).toThrow('Invalid OSC port');
});
it('allows runtime templates in a target hostname', () => {
expect(
parseOutput({
type: 'osc',
targetIP: '{{eventNow.customFields.oscTarget}}',
targetPort: 53000,
address: '/test',
args: '',
}),
).toMatchObject({ targetIP: '{{eventNow.customFields.oscTarget}}' });
});
});
describe('handles HTTP outputs', () => {
it('parses a valid payload', () => {
@@ -41,6 +78,18 @@ describe('parseOutput', () => {
};
expect(() => parseOutput(payload)).toThrow('Unexpected payload type:');
});
it('rejects malformed and unsupported URLs', () => {
expect(() => parseOutput({ type: 'http', url: 'localhost:3000/hook' })).toThrow('Invalid HTTP URL');
expect(() => parseOutput({ type: 'http', url: 'ftp://example.com/hook' })).toThrow('Invalid HTTP URL');
});
it('allows runtime templates in HTTP URLs', () => {
expect(parseOutput({ type: 'http', url: 'http://{{eventNow.customFields.webhookHost}}/hook' })).toEqual({
type: 'http',
url: 'http://{{eventNow.customFields.webhookHost}}/hook',
});
});
});
describe('handles Ontime outputs', () => {
it('parses a valid payload', () => {
@@ -75,12 +75,18 @@ export async function deleteTrigger(req: Request, res: Response<void | ErrorResp
export async function postAutomation(req: Request, res: Response<Automation | ErrorResponse>) {
try {
const newAutomation = await automationDao.addAutomation({
title: req.body.title,
filterRule: req.body.filterRule,
filters: req.body.filters,
outputs: req.body.outputs,
});
const newAutomation = await automationDao.addAutomation(
{
title: req.body.title,
filterRule: req.body.filterRule,
filters: req.body.filters,
outputs: req.body.outputs,
},
req.body.triggers?.map(({ title, trigger }: { title: string; trigger: Trigger['trigger'] }) => ({
title: title.trim(),
trigger,
})),
);
res.status(201).send(newAutomation);
} catch (error) {
const message = getErrorMessage(error);
@@ -2,6 +2,7 @@ import type {
Automation,
AutomationDTO,
AutomationSettings,
AutomationTriggerDTO,
NormalisedAutomation,
ProjectRundowns,
Trigger,
@@ -113,11 +114,20 @@ export async function deleteAll() {
/**
* Adds a validated automation to the store
*/
export async function addAutomation(newAutomation: AutomationDTO): Promise<Automation> {
const automations = getAutomations();
export async function addAutomation(
newAutomation: AutomationDTO,
newTriggers: AutomationTriggerDTO[] = [],
): Promise<Automation> {
const automations = { ...getAutomations() };
const id = getUniqueAutomationId(automations);
automations[id] = { ...newAutomation, id };
await saveChanges({ automations });
const triggers = [...getAutomationTriggers()];
for (const newTrigger of newTriggers) {
triggers.push({ ...newTrigger, id: getUniqueTriggerId(triggers), automationId: id });
}
await saveChanges({ automations, triggers });
return automations[id];
}
@@ -145,24 +155,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 an 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 });
}
/**
@@ -1,12 +1,16 @@
import { isIP } from 'node:net';
import { body, oneOf, param } from 'express-validator';
import {
Automation,
AutomationFilter,
AutomationOutput,
AutomationTriggerDTO,
HTTPOutput,
OSCOutput,
OntimeAction,
SecondarySource,
isTimerLifeCycle,
timerLifecycleValues,
} from 'ontime-types';
@@ -44,7 +48,11 @@ export const validateTriggerPatch = [
requestValidationFunction,
];
export const validateAutomation = [body().custom(parseAutomation), requestValidationFunction];
export const validateAutomation = [
body().custom(parseAutomation),
body('triggers').optional().custom(parseAutomationTriggers),
requestValidationFunction,
];
export const validateAutomationPatch = [
param('id').isString().notEmpty(),
@@ -75,6 +83,20 @@ export function parseAutomation(maybeAutomation: unknown): Automation {
return maybeAutomation as Automation;
}
export function parseAutomationTriggers(maybeTriggers: unknown): AutomationTriggerDTO[] {
assert.isArray(maybeTriggers);
return maybeTriggers.map((maybeTrigger) => {
assert.isObject(maybeTrigger);
assert.hasKeys(maybeTrigger, ['title', 'trigger']);
assert.isString(maybeTrigger.title);
assert.isString(maybeTrigger.trigger);
if (!maybeTrigger.title.trim() || !isTimerLifeCycle(maybeTrigger.trigger)) {
throw new Error('Invalid automation trigger');
}
return { title: maybeTrigger.title.trim(), trigger: maybeTrigger.trigger };
});
}
function validateFilters(filters: Array<unknown>): filters is AutomationFilter[] {
filters.forEach((condition) => {
assert.isObject(condition);
@@ -163,6 +185,21 @@ function parseOSCOutput(maybeOSCOutput: object): OSCOutput {
assert.isString(maybeOSCOutput.address);
assert.isString(maybeOSCOutput.args);
const target = replaceAutomationTemplates(maybeOSCOutput.targetIP.trim(), 'template.local');
const isHostname =
target === 'localhost' ||
/^(?=.{1,253}$)(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)+[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?$/i.test(target);
if (isIP(target) === 0 && !isHostname) {
throw new Error('Invalid OSC target');
}
if (
!Number.isInteger(maybeOSCOutput.targetPort) ||
maybeOSCOutput.targetPort < 1 ||
maybeOSCOutput.targetPort > 65535
) {
throw new Error('Invalid OSC port');
}
return {
type: 'osc',
targetIP: maybeOSCOutput.targetIP,
@@ -176,12 +213,25 @@ function parseHTTPOutput(maybeHTTPOutput: object): HTTPOutput {
assert.hasKeys(maybeHTTPOutput, ['url']);
assert.isString(maybeHTTPOutput.url);
try {
const url = new URL(replaceAutomationTemplates(maybeHTTPOutput.url, 'template'));
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || !url.hostname) {
throw new Error('Invalid HTTP URL');
}
} catch {
throw new Error('Invalid HTTP URL');
}
return {
type: 'http',
url: maybeHTTPOutput.url,
};
}
function replaceAutomationTemplates(value: string, replacement: string): string {
return value.replace(/{{.*?}}/g, replacement);
}
function parseOntimeAction(maybeOntimeAction: object): OntimeAction {
assert.hasKeys(maybeOntimeAction, ['action']);
assert.isString(maybeOntimeAction.action);