feat(automations): add delayed execution for automation rules

This commit is contained in:
Tanmay Deep Sharma
2026-07-10 10:00:07 +05:30
parent bddb561546
commit 30ee4a61fc
35 changed files with 2204 additions and 15 deletions
+1
View File
@@ -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',
@@ -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);
};
@@ -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",
@@ -10,6 +10,7 @@ const START_VALUE = {
name: null,
description: null,
event_name: 'conversation_created',
execution_delay: null,
conditions: [
{
attribute_key: 'status',
@@ -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') }}
</p>
</div>
<div v-if="allowsDelayedExecution" class="mb-6">
<label :class="{ error: errors.execution_delay }">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.LABEL') }}
</label>
<div class="flex flex-wrap items-center gap-4 mt-1">
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input v-model="executeMode" type="radio" value="immediate" />
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.IMMEDIATELY') }}
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input v-model="executeMode" type="radio" value="delayed" />
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.AFTER_DELAY') }}
</label>
<template v-if="executeMode === 'delayed'">
<input
v-model.number="delayValue"
type="number"
min="1"
class="!m-0 !w-24"
/>
<select v-model="delayUnit" class="!m-0 !w-32">
<option
v-for="unit in DELAY_UNITS"
:key="unit.key"
:value="unit.key"
>
{{ $t(`AUTOMATION.ADD.FORM.EXECUTE.UNITS.${unit.key}`) }}
</option>
</select>
</template>
</div>
<span v-if="errors.execution_delay" class="text-xs text-n-ruby-9">
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ERROR') }}
</span>
<p
v-else-if="executeMode === 'delayed'"
class="text-xs text-n-slate-11 pt-1 mb-0"
>
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }}
</p>
</div>
<!-- Conditions Start -->
<section class="mb-5">
<label>
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import { formatDelay } from 'dashboard/helper/automationHelper';
import Button from 'dashboard/components-next/button/Button.vue';
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { BaseTableRow, BaseTableCell } from 'dashboard/components-next/table';
@@ -43,6 +44,16 @@ const automationActive = computed({
<span class="text-body-main text-n-slate-12 truncate">
{{ automation.name }}
</span>
<span
v-if="automation.execution_delay"
class="text-xs px-1.5 py-0.5 rounded-md bg-n-alpha-2 text-n-slate-11 whitespace-nowrap flex-shrink-0"
>
{{
$t('AUTOMATION.LIST.DELAY_BADGE', {
delay: formatDelay(automation.execution_delay),
})
}}
</span>
<div class="w-px h-3 rounded-lg bg-n-weak flex-shrink-0" />
<span class="text-body-main text-n-slate-11 truncate">
{{ automation.description }}
@@ -52,6 +52,14 @@ const isSLAEnabled = computed(() =>
getters['accounts/isFeatureEnabledonAccount'].value(accountId.value, 'sla')
);
const showDelayDisabledBanner = computed(
() =>
!getters['accounts/isFeatureEnabledonAccount'].value(
accountId.value,
'delayed_automations'
) && records.value.some(automation => automation.execution_delay)
);
onMounted(() => {
store.dispatch('inboxes/get');
store.dispatch('agents/get');
@@ -212,6 +220,12 @@ const tableHeaders = computed(() => {
</BaseSettingsHeader>
</template>
<template #body>
<div
v-if="showDelayDisabledBanner"
class="px-4 py-3 mb-4 text-sm rounded-lg bg-n-amber-3 text-n-amber-12"
>
{{ $t('AUTOMATION.LIST.DELAY_DISABLED_BANNER') }}
</div>
<BaseTable
:headers="tableHeaders"
:items="filteredRecords"