Files
2026-08-14 13:04:09 +00:00

597 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { sendAt } from 'cron';
import moment from 'moment-timezone';
import type {
ITriggerFunctions,
INodeType,
INodeTypeDescription,
ITriggerResponse,
Cron,
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow';
import {
intervalToRecurrence,
recurrenceCheck,
resetStaleRecurrence,
toCronExpression,
toCronSource,
validateInterval,
withIntervalDefaults,
} from './GenericFunctions';
import type { IRecurrenceRule, Rule } from './SchedulerInterface';
export class ScheduleTrigger implements INodeType {
description: INodeTypeDescription = {
displayName: 'Schedule Trigger',
name: 'scheduleTrigger',
icon: 'node:schedule-trigger',
iconColor: 'black',
group: ['trigger', 'schedule'],
version: [1, 1.1, 1.2, 1.3, 1.4],
description: 'Triggers the workflow on a given schedule',
eventTriggerDescription: '',
activationMessage:
'Your schedule trigger will now trigger executions on the schedule you have defined.',
defaults: {
name: 'Schedule Trigger',
},
inputs: [],
outputs: [NodeConnectionTypes.Main],
properties: [
{
displayName:
"This workflow will run on the schedule you define here once you publish it.<br><br>For testing, you can also trigger it manually: by going back to the canvas and clicking 'execute workflow'",
name: 'notice',
type: 'notice',
default: '',
},
{
displayName: 'Trigger Rules',
name: 'rule',
placeholder: 'Add Rule',
type: 'fixedCollection',
typeOptions: {
multipleValues: true,
},
default: {
interval: [
{
field: 'days',
},
],
},
options: [
{
name: 'interval',
displayName: 'Trigger Interval',
builderHint: {
propertyHint:
'You can add multiple intervals to trigger at different times. Use "Custom (Cron)" for more specific scheduling patterns.',
},
values: [
{
displayName: 'Trigger Interval',
name: 'field',
type: 'options',
default: 'days',
// eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items
options: [
{
name: 'Seconds',
value: 'seconds',
},
{
name: 'Minutes',
value: 'minutes',
},
{
name: 'Hours',
value: 'hours',
},
{
name: 'Days',
value: 'days',
},
{
name: 'Weeks',
value: 'weeks',
},
{
name: 'Months',
value: 'months',
},
{
name: 'Custom (Cron)',
value: 'cronExpression',
},
],
},
{
displayName: 'Seconds Between Triggers',
name: 'secondsInterval',
type: 'number',
default: 30,
displayOptions: {
show: {
field: ['seconds'],
},
},
description: 'Number of seconds between each workflow trigger',
hint: 'Must be in range 1-59',
},
{
displayName: 'Minutes Between Triggers',
name: 'minutesInterval',
type: 'number',
default: 5,
displayOptions: {
show: {
field: ['minutes'],
},
},
description: 'Number of minutes between each workflow trigger',
hint: 'Must be in range 1-59',
},
{
displayName: 'Hours Between Triggers',
name: 'hoursInterval',
type: 'number',
displayOptions: {
show: {
field: ['hours'],
},
},
default: 1,
description: 'Number of hours between each workflow trigger',
hint: 'Must be in range 1-23',
},
{
displayName: 'Days Between Triggers',
name: 'daysInterval',
type: 'number',
displayOptions: {
show: {
field: ['days'],
},
},
default: 1,
description: 'Number of days between each workflow trigger',
hint: 'Must be in range 1-31',
},
{
displayName: 'Weeks Between Triggers',
name: 'weeksInterval',
type: 'number',
displayOptions: {
show: {
field: ['weeks'],
},
},
default: 1,
description: 'Would run every week unless specified otherwise',
},
{
displayName: 'Months Between Triggers',
name: 'monthsInterval',
type: 'number',
typeOptions: {
minValue: 1,
},
displayOptions: {
show: {
field: ['months'],
},
},
default: 1,
description: 'Would run every month unless specified otherwise',
},
{
displayName: 'Trigger at Day of Month',
name: 'triggerAtDayOfMonth',
type: 'number',
displayOptions: {
show: {
field: ['months'],
},
},
typeOptions: {
minValue: 1,
maxValue: 31,
},
default: 1,
description: 'The day of the month to trigger (1-31)',
hint: 'If a month doesnt have this day, the node wont trigger',
},
{
displayName: 'Trigger on Weekdays',
name: 'triggerAtDay',
type: 'multiOptions',
displayOptions: {
show: {
field: ['weeks'],
},
},
typeOptions: {
maxValue: 7,
},
options: [
{
name: 'Monday',
value: 1,
},
{
name: 'Tuesday',
value: 2,
},
{
name: 'Wednesday',
value: 3,
},
{
name: 'Thursday',
value: 4,
},
{
name: 'Friday',
value: 5,
},
{
name: 'Saturday',
value: 6,
},
{
name: 'Sunday',
value: 0,
},
],
default: [0],
},
{
displayName: 'Trigger at Hour',
name: 'triggerAtHour',
type: 'options',
default: 0,
displayOptions: {
show: {
field: ['days', 'weeks', 'months'],
},
},
options: [
{
name: 'Midnight',
displayName: 'Midnight',
value: 0,
},
{
name: '1am',
displayName: '1am',
value: 1,
},
{
name: '2am',
displayName: '2am',
value: 2,
},
{
name: '3am',
displayName: '3am',
value: 3,
},
{
name: '4am',
displayName: '4am',
value: 4,
},
{
name: '5am',
displayName: '5am',
value: 5,
},
{
name: '6am',
displayName: '6am',
value: 6,
},
{
name: '7am',
displayName: '7am',
value: 7,
},
{
name: '8am',
displayName: '8am',
value: 8,
},
{
name: '9am',
displayName: '9am',
value: 9,
},
{
name: '10am',
displayName: '10am',
value: 10,
},
{
name: '11am',
displayName: '11am',
value: 11,
},
{
name: 'Noon',
displayName: 'Noon',
value: 12,
},
{
name: '1pm',
displayName: '1pm',
value: 13,
},
{
name: '2pm',
displayName: '2pm',
value: 14,
},
{
name: '3pm',
displayName: '3pm',
value: 15,
},
{
name: '4pm',
displayName: '4pm',
value: 16,
},
{
name: '5pm',
displayName: '5pm',
value: 17,
},
{
name: '6pm',
displayName: '6pm',
value: 18,
},
{
name: '7pm',
displayName: '7pm',
value: 19,
},
{
name: '8pm',
displayName: '8pm',
value: 20,
},
{
name: '9pm',
displayName: '9pm',
value: 21,
},
{
name: '10pm',
displayName: '10pm',
value: 22,
},
{
name: '11pm',
displayName: '11pm',
value: 23,
},
],
description: 'The hour of the day to trigger',
},
{
displayName: 'Trigger at Minute',
name: 'triggerAtMinute',
type: 'number',
default: 0,
displayOptions: {
show: {
field: ['hours', 'days', 'weeks', 'months'],
},
},
typeOptions: {
minValue: 0,
maxValue: 59,
},
description: 'The minute past the hour to trigger (0-59)',
},
{
displayName:
'You can find help generating your cron expression <a href="https://crontab.guru/examples.html" target="_blank">here</a>',
name: 'notice',
type: 'notice',
displayOptions: {
show: {
field: ['cronExpression'],
},
},
default: '',
},
{
displayName: 'Expression',
name: 'expression',
type: 'string',
default: '',
placeholder: 'eg. 0 15 * 1 sun',
displayOptions: {
show: {
field: ['cronExpression'],
},
},
hint: 'Format: ([Second]) [Minute] [Hour] [Day of Month] [Month] [Day of Week]',
},
],
},
],
},
{
displayName: 'If Execution Is Missed',
name: 'misfirePolicy',
type: 'options',
default: 'skip',
options: [
{ name: 'Run the Most Recent Missed Execution Per Rule', value: 'coalesce' },
{ name: 'Run the Most Recent Missed Execution', value: 'coalesce_owner' },
{ name: "Don't Run Missed Executions", value: 'skip' },
],
hint: 'Applies once an execution is later than the grace period set below',
isNodeSetting: true,
noDataExpression: true, // read at activation, so an expression would never be resolved
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.4 } }],
},
},
},
{
displayName: 'Missed Execution Grace Period (Seconds)',
name: 'misfireGraceSeconds',
type: 'number',
default: 0, // `0` means "use the instance settings"
typeOptions: { minValue: 0, numberPrecision: 0 },
description:
'How late an execution may start before it counts as missed. Set to 0 to use the instance setting.',
isNodeSetting: true,
noDataExpression: true,
displayOptions: {
show: {
'@version': [{ _cnd: { gte: 1.4 } }],
},
},
},
{
// Temporary escape hatch for the durable-scheduler rollout (preview to
// GA): keeps this trigger on the legacy in-memory scheduler while testing.
// Hidden unless N8N_ENV_FEAT_SKIP_DURABLE_SCHEDULER is enabled. Remove at GA.
displayName: 'Skip Durable Scheduler',
name: 'skipDurableScheduler',
type: 'boolean',
default: false,
isNodeSetting: true,
envFeatureFlag: 'SKIP_DURABLE_SCHEDULER',
description:
'Whether to run this trigger through the legacy in-memory scheduler instead of the durable scheduler',
},
],
};
async trigger(this: ITriggerFunctions): Promise<ITriggerResponse> {
const version = this.getNode().typeVersion;
const { interval = [{}] } = this.getNodeParameter('rule', {}) as Partial<Rule>;
const intervals = interval.map(withIntervalDefaults);
const timezone = this.getTimezone();
const staticData = this.getWorkflowStaticData('node') as {
recurrenceRules: Array<number | undefined>;
recurrenceRuleSignatures: Array<string | undefined>;
};
if (!staticData.recurrenceRules) {
staticData.recurrenceRules = [];
}
if (!staticData.recurrenceRuleSignatures) {
staticData.recurrenceRuleSignatures = [];
}
if (version >= 1.3) {
for (let i = 0; i < intervals.length; i++) {
validateInterval(this.getNode(), i, intervals[i]);
}
}
const workflowId = this.getWorkflow().id;
const nodeId = this.getNode().id;
const executeTrigger = (
recurrence: IRecurrenceRule,
skipRecurrenceCheck = false,
scheduledTime?: Date,
) => {
if (!skipRecurrenceCheck) {
const shouldTrigger = recurrenceCheck(recurrence, staticData.recurrenceRules, timezone);
if (!shouldTrigger) return;
}
const momentTz = moment.tz(timezone);
const resultData = {
timestamp: momentTz.toISOString(true),
'Readable date': momentTz.format('MMMM Do YYYY, h:mm:ss a'),
'Readable time': momentTz.format('h:mm:ss a'),
'Day of week': momentTz.format('dddd'),
Year: momentTz.format('YYYY'),
Month: momentTz.format('MMMM'),
'Day of month': momentTz.format('DD'),
Hour: momentTz.format('HH'),
Minute: momentTz.format('mm'),
Second: momentTz.format('ss'),
Timezone: `${timezone} (UTC${momentTz.format('Z')})`,
};
// The workflowId should always be defined, but if it isn't we skip
// the deduplication key.
const deduplicationKey =
workflowId && scheduledTime
? `${workflowId}:${nodeId}:${scheduledTime.toISOString()}`
: undefined;
this.emit(
[this.helpers.returnJsonArray([resultData])],
/* responsePromise= */ undefined,
/* donePromise= */ undefined,
deduplicationKey,
);
};
const nodeKey = `${workflowId ?? ''}:${nodeId}`;
const rules = intervals.map((interval, i) => ({
interval,
cronExpression: toCronExpression(interval, nodeKey),
recurrence: intervalToRecurrence(interval, i),
}));
if (this.getMode() !== 'manual') {
// Re-arm rules left stale by a previous schedule config (scheduled mode only).
resetStaleRecurrence(staticData, rules);
for (const { interval, cronExpression, recurrence } of rules) {
try {
const cron: Cron = {
expression: cronExpression,
recurrence,
source: toCronSource(interval),
};
this.helpers.registerCron(cron, (scheduledTime: Date) =>
executeTrigger(recurrence, /* skipRecurrenceCheck= */ false, scheduledTime),
);
} catch (error) {
if (interval.field === 'cronExpression') {
throw new NodeOperationError(this.getNode(), 'Invalid cron expression', {
description: 'More information on how to build them at https://crontab.guru/',
});
} else {
throw error;
}
}
}
return {};
} else {
const manualTriggerFunction = async () => {
const { interval, cronExpression, recurrence } = rules[0];
if (interval.field === 'cronExpression') {
try {
sendAt(cronExpression);
} catch (error) {
throw new NodeOperationError(this.getNode(), 'Invalid cron expression', {
description: 'More information on how to build them at https://crontab.guru/',
});
}
}
executeTrigger(recurrence, true);
};
return { manualTriggerFunction };
}
}
}