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') }}

+
+ +
+ + + +
+ + {{ $t('AUTOMATION.ADD.FORM.EXECUTE.ERROR') }} + +

+ {{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }} +

+