From 30ee4a61fc164a62621376afd5243629c77d2a87 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma
Date: Fri, 10 Jul 2026 10:00:07 +0530
Subject: [PATCH] feat(automations): add delayed execution for automation rules
---
.../accounts/automation_rules_controller.rb | 17 +-
app/javascript/dashboard/featureFlags.js | 1 +
.../dashboard/helper/automationHelper.js | 6 +
.../dashboard/i18n/locale/en/automation.json | 16 +-
.../settings/automation/AddAutomationRule.vue | 1 +
.../automation/AutomationRuleForm.vue | 100 +++++
.../settings/automation/AutomationRuleRow.vue | 11 +
.../dashboard/settings/automation/Index.vue | 14 +
.../process_pending_execution_job.rb | 53 +++
.../trigger_pending_executions_job.rb | 42 ++
app/jobs/trigger_scheduled_items_job.rb | 3 +
app/listeners/automation_rule_listener.rb | 17 +-
app/models/account.rb | 1 +
app/models/automation_rule.rb | 35 +-
.../automation_rule_pending_execution.rb | 116 +++++
app/models/conversation.rb | 7 +
.../partials/_automation_rule.json.jbuilder | 1 +
config/features.yml | 5 +
config/installation_config.yml | 7 +
...add_execution_delay_to_automation_rules.rb | 5 +
..._add_status_changed_at_to_conversations.rb | 5 +
...eate_automation_rule_pending_executions.rb | 21 +
db/schema.rb | 22 +-
...delayed-automations-implementation-plan.md | 332 +++++++++++++++
docs/delayed-automations-overview.md | 149 +++++++
docs/delayed-automations.md | 335 +++++++++++++++
...e-based-automations-phasing-and-rollout.md | 395 ++++++++++++++++++
.../automation_rules_controller_spec.rb | 57 +++
.../automation_rule_pending_executions.rb | 10 +
.../process_pending_execution_job_spec.rb | 119 ++++++
.../trigger_pending_executions_job_spec.rb | 52 +++
.../automation_rule_listener_spec.rb | 33 ++
.../automation_rule_pending_execution_spec.rb | 169 ++++++++
spec/models/automation_rule_spec.rb | 38 ++
spec/models/conversation_spec.rb | 24 ++
35 files changed, 2204 insertions(+), 15 deletions(-)
create mode 100644 app/jobs/automation_rules/process_pending_execution_job.rb
create mode 100644 app/jobs/automation_rules/trigger_pending_executions_job.rb
create mode 100644 app/models/automation_rule_pending_execution.rb
create mode 100644 db/migrate/20260709060000_add_execution_delay_to_automation_rules.rb
create mode 100644 db/migrate/20260709060100_add_status_changed_at_to_conversations.rb
create mode 100644 db/migrate/20260709060200_create_automation_rule_pending_executions.rb
create mode 100644 docs/delayed-automations-implementation-plan.md
create mode 100644 docs/delayed-automations-overview.md
create mode 100644 docs/delayed-automations.md
create mode 100644 docs/time-based-automations-phasing-and-rollout.md
create mode 100644 spec/factories/automation_rule_pending_executions.rb
create mode 100644 spec/jobs/automation_rules/process_pending_execution_job_spec.rb
create mode 100644 spec/jobs/automation_rules/trigger_pending_executions_job_spec.rb
create mode 100644 spec/models/automation_rule_pending_execution_spec.rb
diff --git a/app/controllers/api/v1/accounts/automation_rules_controller.rb b/app/controllers/api/v1/accounts/automation_rules_controller.rb
index 0840d0eea..4c1b2b373 100644
--- a/app/controllers/api/v1/accounts/automation_rules_controller.rb
+++ b/app/controllers/api/v1/accounts/automation_rules_controller.rb
@@ -3,6 +3,7 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
before_action :check_authorization
before_action :fetch_automation_rule, only: [:show, :update, :destroy, :clone]
+ before_action :ensure_execution_delay_allowed, only: [:create, :update]
def index
@automation_rules = Current.account.automation_rules
@@ -55,13 +56,27 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
private
def automation_rules_permit
+ permitted_attributes = [:name, :description, :event_name, :active]
+ permitted_attributes << :execution_delay if delayed_automations_enabled?
+
params.permit(
- :name, :description, :event_name, :active,
+ *permitted_attributes,
conditions: [:attribute_key, :filter_operator, :query_operator, :custom_attribute_type, { values: [] }],
actions: [:action_name, { action_params: [] }]
)
end
+ def ensure_execution_delay_allowed
+ return if delayed_automations_enabled?
+ return if params[:execution_delay].blank?
+
+ render json: { error: 'Delayed automations are not enabled for this account.' }, status: :unprocessable_entity
+ end
+
+ def delayed_automations_enabled?
+ Current.account.feature_enabled?('delayed_automations')
+ end
+
def fetch_automation_rule
@automation_rule = Current.account.automation_rules.find_by(id: params[:id])
end
diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js
index b9f59eba0..a14b1e3a2 100644
--- a/app/javascript/dashboard/featureFlags.js
+++ b/app/javascript/dashboard/featureFlags.js
@@ -10,6 +10,7 @@ export const FEATURE_FLAGS = {
CANNED_RESPONSES: 'canned_responses',
CRM: 'crm',
CUSTOM_ATTRIBUTES: 'custom_attributes',
+ DELAYED_AUTOMATIONS: 'delayed_automations',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
diff --git a/app/javascript/dashboard/helper/automationHelper.js b/app/javascript/dashboard/helper/automationHelper.js
index 8aed8dcda..c66dc204c 100644
--- a/app/javascript/dashboard/helper/automationHelper.js
+++ b/app/javascript/dashboard/helper/automationHelper.js
@@ -208,6 +208,12 @@ export const generateAutomationPayload = payload => {
return automation;
};
+export const formatDelay = minutes => {
+ if (minutes % 1440 === 0) return `${minutes / 1440}d`;
+ if (minutes % 60 === 0) return `${minutes / 60}h`;
+ return `${minutes}m`;
+};
+
export const isCustomAttribute = (attrs, key) => {
return attrs.find(attr => attr.key === key);
};
diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json
index 2c4852dc8..805674eba 100644
--- a/app/javascript/dashboard/i18n/locale/en/automation.json
+++ b/app/javascript/dashboard/i18n/locale/en/automation.json
@@ -28,6 +28,18 @@
"PLACEHOLDER": "Please select one",
"ERROR": "Event is required"
},
+ "EXECUTE": {
+ "LABEL": "Execute",
+ "IMMEDIATELY": "Immediately",
+ "AFTER_DELAY": "After a delay of",
+ "UNITS": {
+ "MINUTES": "Minutes",
+ "HOURS": "Hours",
+ "DAYS": "Days"
+ },
+ "ERROR": "Delay must be between 10 minutes and 30 days",
+ "HELP_TEXT": "Delayed rules run only if the conditions still hold when the delay ends, and apply to conversations with activity after the rule is created."
+ },
"CONDITIONS": {
"LABEL": "Conditions"
},
@@ -49,7 +61,9 @@
"CREATED_ON": "Created on",
"ACTIONS": "Actions"
},
- "404": "No automation rules found"
+ "404": "No automation rules found",
+ "DELAY_BADGE": "Runs after {delay}",
+ "DELAY_DISABLED_BANNER": "Delayed execution is disabled for this account. Rules with a delay will not run until it is enabled again."
},
"DELETE": {
"TITLE": "Delete Automation Rule",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue
index 472dfe08e..5731b2d7f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue
@@ -10,6 +10,7 @@ const START_VALUE = {
name: null,
description: null,
event_name: 'conversation_created',
+ execution_delay: null,
conditions: [
{
attribute_key: 'status',
diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue
index d78c40749..3a6cc8a8b 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue
@@ -14,6 +14,7 @@ import {
showActionInput,
} from 'dashboard/helper/automationHelper';
import { validateAutomation } from 'dashboard/helper/validations';
+import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { AUTOMATION_RULE_EVENTS, AUTOMATION_ACTION_TYPES } from './constants';
const props = defineProps({
@@ -71,6 +72,14 @@ const INPUT_TYPE_MAP = {
date: 'date',
};
+const DELAY_UNITS = [
+ { key: 'MINUTES', factor: 1 },
+ { key: 'HOURS', factor: 60 },
+ { key: 'DAYS', factor: 1440 },
+];
+const MIN_DELAY_MINUTES = 10;
+const MAX_DELAY_MINUTES = 43200; // 30 days
+
const { t } = useI18n();
const { isCloudFeatureEnabled } = useAccount();
const { operators } = useOperators();
@@ -184,6 +193,50 @@ const hasActionErrors = computed(() =>
Object.keys(errors.value).some(key => key.startsWith('action_'))
);
+const allowsDelayedExecution = computed(() =>
+ isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS)
+);
+
+const executeMode = ref('immediate');
+const delayValue = ref(4);
+const delayUnit = ref('HOURS');
+
+const delayInMinutes = computed(() => {
+ const factor =
+ DELAY_UNITS.find(unit => unit.key === delayUnit.value)?.factor || 1;
+ return Math.round(Number(delayValue.value) * factor);
+});
+
+const executionDelayInvalid = computed(
+ () =>
+ executeMode.value === 'delayed' &&
+ (!Number.isFinite(delayInMinutes.value) ||
+ delayInMinutes.value < MIN_DELAY_MINUTES ||
+ delayInMinutes.value > MAX_DELAY_MINUTES)
+);
+
+// Hydrate the delay controls from the automation, using the largest clean unit.
+const syncDelayFromAutomation = () => {
+ const delay = automation.value?.execution_delay;
+ executeMode.value = delay ? 'delayed' : 'immediate';
+ if (!delay) {
+ delayValue.value = 4;
+ delayUnit.value = 'HOURS';
+ return;
+ }
+ const unit =
+ [...DELAY_UNITS].reverse().find(u => delay % u.factor === 0) ||
+ DELAY_UNITS[0];
+ delayUnit.value = unit.key;
+ delayValue.value = delay / unit.factor;
+};
+
+watch([executeMode, delayInMinutes], () => {
+ if (!automation.value || !allowsDelayedExecution.value) return;
+ automation.value.execution_delay =
+ executeMode.value === 'delayed' ? delayInMinutes.value : null;
+});
+
watch(
() => automation.value,
() => {
@@ -218,6 +271,7 @@ const syncCustomAttributeTypes = () => {
const open = () => {
resetValidation();
+ syncDelayFromAutomation();
dialogRef.value?.open();
};
@@ -230,8 +284,13 @@ const emitSaveAutomation = () => {
syncCustomAttributeTypes();
const conditionsValid = isConditionsValid();
errors.value = validateAutomation(automation.value);
+ if (allowsDelayedExecution.value && executionDelayInvalid.value) {
+ errors.value.execution_delay = true;
+ }
if (Object.keys(errors.value).length === 0 && conditionsValid) {
const payload = generateAutomationPayload(automation.value);
+ // The API rejects the param when the feature is off; existing values are kept server-side.
+ if (!allowsDelayedExecution.value) delete payload.execution_delay;
emit('save', payload, props.mode);
}
};
@@ -293,6 +352,47 @@ defineExpose({ open, close });
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
+