diff --git a/app/javascript/dashboard/i18n/locale/en/automation.json b/app/javascript/dashboard/i18n/locale/en/automation.json index 6fdc55314..9c3f9f0f1 100644 --- a/app/javascript/dashboard/i18n/locale/en/automation.json +++ b/app/javascript/dashboard/i18n/locale/en/automation.json @@ -68,6 +68,10 @@ "ACTIONS": "Actions" }, "404": "No automation rules found", + "SECTIONS": { + "INSTANT": "Automations", + "DELAYED": "Delayed (Wait)" + }, "DELAY_BADGE": "Runs after {delay}", "DELAY_DISABLED_BANNER": "Waits are disabled for this account. Rules with a wait won't run until it is enabled again." }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue index 10fe92b9d..b3b604ad1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AutomationRuleForm.vue @@ -80,8 +80,14 @@ const DELAY_UNITS = [ ]; const MIN_DELAY_MINUTES = 10; const MAX_DELAY_MINUTES = 43200; // 30 days -// Conversation-level delayed rules can filter on status plus immutable attributes (inbox). -const DELAYED_CONVERSATION_ATTRS = ['status', 'inbox_id']; +// The only valid delayed (wait) rule shapes: each supported event maps to the conditions that +// stay meaningful across the wait — status / message_type re-checked at fire time, plus the +// immutable inbox for scoping. Every other event/condition is hidden while the wait is on. +const DELAYED_EVENT_ATTRS = { + conversation_updated: ['status', 'inbox_id'], + message_created: ['message_type', 'inbox_id'], +}; +const DELAYED_EVENTS = Object.keys(DELAYED_EVENT_ATTRS); const { t } = useI18n(); const { isCloudFeatureEnabled } = useAccount(); @@ -129,11 +135,10 @@ const filterTypes = computed(() => { if (!event || !props.automationTypes[event]) return []; let attributes = getTranslatedAttributes(props.automationTypes, event); - // A delayed conversation-level rule can filter only on status and immutable attributes (inbox). - if (isDelayed.value && event !== 'message_created') { - attributes = attributes.filter(attr => - DELAYED_CONVERSATION_ATTRS.includes(attr.key) - ); + // A delayed rule can only filter on the attributes that stay meaningful across the wait. + if (isDelayed.value) { + const allowed = DELAYED_EVENT_ATTRS[event] || []; + attributes = attributes.filter(attr => allowed.includes(attr.key)); } return attributes.map(attr => { @@ -174,12 +179,15 @@ const filterTypes = computed(() => { }); }); -const automationRuleEvents = computed(() => - AUTOMATION_RULE_EVENTS.map(event => ({ +const automationRuleEvents = computed(() => { + const events = isDelayed.value + ? AUTOMATION_RULE_EVENTS.filter(event => DELAYED_EVENTS.includes(event.key)) + : AUTOMATION_RULE_EVENTS; + return events.map(event => ({ ...event, value: t(`AUTOMATION.EVENTS.${event.value}`), - })) -); + })); +}); const hasAutomationMutated = computed(() => { return Boolean( @@ -221,15 +229,15 @@ const delayRestrictionReason = computed(() => { ) { return 'ATTRIBUTE_CHANGED'; } + const allowed = DELAYED_EVENT_ATTRS[eventName.value]; + if (!allowed) return 'UNSUPPORTED_EVENT'; if ( - eventName.value !== 'message_created' && conditions.some( condition => - condition.attribute_key && - !DELAYED_CONVERSATION_ATTRS.includes(condition.attribute_key) + condition.attribute_key && !allowed.includes(condition.attribute_key) ) ) { - return 'CONVERSATION_NON_STATUS'; + return 'UNSUPPORTED_CONDITION'; } return null; }); @@ -311,6 +319,16 @@ const resetToSupportedCondition = () => { ]; }; +// A delay narrows the event list, so if the wait is turned on while an unsupported event is +// selected (e.g. conversation_opened), switch to a meaningful default and reset its conditions +// and actions the same way the event dropdown would. +watch(isDelayed, delayed => { + if (!delayed || !automation.value) return; + if (DELAYED_EVENTS.includes(automation.value.event_name)) return; + automation.value.event_name = DELAYED_EVENTS[0]; + props.onEventChange(); +}); + // A delay narrows the condition options, so whenever the rule becomes unsupported while the // wait is on — toggling it on, or switching to an event whose default condition isn't allowed // (e.g. conversation_opened defaults to browser_language) — reset to a supported default. diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue index 490624b2f..8ff93b0fb 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue @@ -35,6 +35,31 @@ const filteredRecords = computed(() => { if (!query) return records.value; return picoSearch(records.value, query, ['name', 'description']); }); + +// Delayed (wait) rules run on a different lifecycle, so list them in their own section. +const hasDelayedRecords = computed(() => + records.value.some(automation => automation.execution_delay) +); + +const sections = computed(() => { + const instant = []; + const delayed = []; + filteredRecords.value.forEach(automation => + (automation.execution_delay ? delayed : instant).push(automation) + ); + return [ + { + key: 'instant', + label: t('AUTOMATION.LIST.SECTIONS.INSTANT'), + items: instant, + }, + { + key: 'delayed', + label: t('AUTOMATION.LIST.SECTIONS.DELAYED'), + items: delayed, + }, + ].filter(section => section.items.length); +}); const uiFlags = computed(() => getters['automations/getUIFlags'].value); const accountId = computed(() => getters.getCurrentAccountId.value); @@ -226,25 +251,43 @@ const tableHeaders = computed(() => { > {{ $t('AUTOMATION.LIST.DELAY_DISABLED_BANNER') }} + - + diff --git a/app/models/automation_rule.rb b/app/models/automation_rule.rb index e4aa56da6..ae0bb948b 100644 --- a/app/models/automation_rule.rb +++ b/app/models/automation_rule.rb @@ -23,9 +23,13 @@ class AutomationRule < ApplicationRecord include Reauthorizable EXECUTION_DELAY_RANGE = (10..43_200) # minutes: 10 min to 30 days - # Conversation-level delayed rules key their episode on status; only status and attributes - # that never change after the delay (inbox) are safe to also filter on. - DELAYED_CONVERSATION_ATTRIBUTES = %w[status inbox_id].freeze + # The only valid delayed (wait) rule shapes: each supported event maps to the conditions that + # stay meaningful across the wait — status / message_type re-checked at fire time, plus the + # immutable inbox for scoping. Every other event or condition is rejected for delayed rules. + DELAYED_EVENT_ATTRIBUTES = { + 'conversation_updated' => %w[status inbox_id], + 'message_created' => %w[message_type inbox_id] + }.freeze belongs_to :account has_many :pending_executions, class_name: 'AutomationRulePendingExecution', dependent: :delete_all @@ -116,13 +120,16 @@ class AutomationRule < ApplicationRecord errors.add(:execution_delay, 'cannot be used with attribute_changed conditions.') end - # Conversation-level episodes key on status_changed_at alone. Mutable attributes would collapse - # distinct periods into one episode, so only status and immutable filters (inbox) are allowed. + # Delayed rules are limited to the shapes whose episode keys stay meaningful across the wait: + # conversation_updated on status/inbox, and message_created on message_type/inbox. def execution_delay_supported_event - return if execution_delay.blank? || conditions.blank? || event_name == 'message_created' - return if conditions.all? { |obj| DELAYED_CONVERSATION_ATTRIBUTES.include?(obj['attribute_key']) } + return if execution_delay.blank? - errors.add(:execution_delay, 'only supports status and inbox conditions for conversation-level events.') + allowed = DELAYED_EVENT_ATTRIBUTES[event_name] + return errors.add(:execution_delay, 'is only supported for conversation_updated and message_created events.') if allowed.nil? + return if conditions.blank? || conditions.all? { |obj| allowed.include?(obj['attribute_key']) } + + errors.add(:execution_delay, "only supports #{allowed.join(' and ')} conditions for #{event_name} events.") end def execution_config_changed?