Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a6ab8de3d | ||
|
|
a745f418cf | ||
|
|
305e13c830 | ||
|
|
d5e30f7545 | ||
|
|
df7ec2f596 |
@@ -3,7 +3,6 @@ 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
|
||||
@@ -49,9 +48,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
def clone
|
||||
automation_rule = Current.account.automation_rules.find_by(id: params[:automation_rule_id])
|
||||
new_rule = automation_rule.dup
|
||||
# dup copies execution_delay; drop it when the feature is off so clone can't create new
|
||||
# delayed rules that create/update would reject.
|
||||
new_rule.execution_delay = nil unless delayed_automations_enabled?
|
||||
new_rule.save!
|
||||
@automation_rule = new_rule
|
||||
end
|
||||
@@ -59,27 +55,13 @@ 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(
|
||||
*permitted_attributes,
|
||||
:name, :description, :event_name, :active,
|
||||
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
|
||||
|
||||
@@ -47,10 +47,18 @@ class Webhooks::WhatsappController < ActionController::API
|
||||
metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata)
|
||||
return if metadata.blank?
|
||||
|
||||
Whatsapp::WebhookChannelFinderService.new(
|
||||
display_phone_number: metadata[:display_phone_number],
|
||||
phone_number_id: metadata[:phone_number_id]
|
||||
).perform
|
||||
phone_number = normalized_phone_number(metadata[:display_phone_number])
|
||||
phone_number_id = metadata[:phone_number_id]
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
|
||||
def normalized_phone_number(phone_number)
|
||||
return if phone_number.blank?
|
||||
|
||||
phone_number = phone_number.to_s
|
||||
phone_number.start_with?('+') ? phone_number : "+#{phone_number}"
|
||||
end
|
||||
|
||||
def inactive_whatsapp_number?
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, useSlots, ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
|
||||
@@ -23,11 +22,8 @@ const props = defineProps({
|
||||
const emit = defineEmits(['goToContactsList', 'toggleBlock']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const slots = useSlots();
|
||||
const route = useRoute();
|
||||
|
||||
const isContactSidebarOpen = ref(false);
|
||||
|
||||
const contactId = computed(() => route.params.contactId);
|
||||
|
||||
const selectedContactName = computed(() => {
|
||||
@@ -60,26 +56,15 @@ const handleBreadcrumbClick = () => {
|
||||
const toggleBlock = () => {
|
||||
emit('toggleBlock', isContactBlocked.value);
|
||||
};
|
||||
|
||||
const handleConversationSidebarToggle = () => {
|
||||
isContactSidebarOpen.value = !isContactSidebarOpen.value;
|
||||
};
|
||||
|
||||
const closeMobileSidebar = () => {
|
||||
if (!isContactSidebarOpen.value) return;
|
||||
isContactSidebarOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex w-full h-full overflow-hidden justify-evenly bg-n-surface-1"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
|
||||
>
|
||||
<div class="flex flex-col w-full h-full transition-all duration-300">
|
||||
<header class="sticky top-0 z-10 px-6 3xl:px-0">
|
||||
<div class="w-full mx-auto max-w-[40.625rem]">
|
||||
<div class="w-full mx-auto max-w-5xl">
|
||||
<div
|
||||
class="flex flex-col xs:flex-row items-start xs:items-center justify-between w-full py-7 gap-2"
|
||||
>
|
||||
@@ -119,81 +104,10 @@ const closeMobileSidebar = () => {
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto 3xl:px-px">
|
||||
<div class="w-full py-4 mx-auto max-w-[40.625rem]">
|
||||
<div class="w-full py-4 mx-auto max-w-5xl">
|
||||
<slot name="default" />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Desktop sidebar -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile sidebar container -->
|
||||
<div
|
||||
v-if="slots.sidebar"
|
||||
class="lg:hidden fixed top-0 ltr:right-0 rtl:left-0 h-full z-50 flex justify-end transition-all duration-200 ease-in-out"
|
||||
:class="isContactSidebarOpen ? 'w-full' : 'w-16'"
|
||||
>
|
||||
<!-- Toggle button -->
|
||||
<div
|
||||
v-on-click-outside="[
|
||||
closeMobileSidebar,
|
||||
{ ignore: ['#contact-sidebar-content'] },
|
||||
]"
|
||||
class="flex items-start p-1 w-fit h-fit relative order-1 xs:top-24 top-28 transition-all bg-n-solid-2 border border-n-weak duration-500 ease-in-out"
|
||||
:class="[
|
||||
isContactSidebarOpen
|
||||
? 'justify-end ltr:rounded-l-full rtl:rounded-r-full ltr:rounded-r-none rtl:rounded-l-none'
|
||||
: 'justify-center rounded-full ltr:mr-6 rtl:ml-6',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
ghost
|
||||
slate
|
||||
sm
|
||||
class="!rounded-full rtl:rotate-180"
|
||||
:class="{ 'bg-n-alpha-2': isContactSidebarOpen }"
|
||||
:icon="
|
||||
isContactSidebarOpen
|
||||
? 'i-lucide-panel-right-close'
|
||||
: 'i-lucide-panel-right-open'
|
||||
"
|
||||
data-contact-sidebar-toggle
|
||||
@click="handleConversationSidebarToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition-transform duration-200 ease-in-out"
|
||||
leave-active-class="transition-transform duration-200 ease-in-out"
|
||||
enter-from-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
enter-to-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-from-class="ltr:translate-x-0 rtl:-translate-x-0"
|
||||
leave-to-class="ltr:translate-x-full rtl:-translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="isContactSidebarOpen"
|
||||
id="contact-sidebar-content"
|
||||
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<slot name="sidebarHeader" />
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
|
||||
<slot name="sidebar" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
+30
-18
@@ -5,6 +5,7 @@ import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
import ContactCustomAttributeItem from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributeItem.vue';
|
||||
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
@@ -108,8 +109,12 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6">
|
||||
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
|
||||
<ContactSidebarSection
|
||||
v-if="hasContactAttributes"
|
||||
:title="t('CONTACTS_LAYOUT.SIDEBAR.TABS.ATTRIBUTES')"
|
||||
body-class="p-0"
|
||||
>
|
||||
<div v-if="!hasNoUsedAttributes" class="flex flex-col px-4 py-1.5">
|
||||
<ContactCustomAttributeItem
|
||||
v-for="attribute in usedAttributes"
|
||||
:key="attribute.id"
|
||||
@@ -117,36 +122,43 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
:attribute="attribute"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!hasNoUnusedAttributes" class="flex items-center gap-3">
|
||||
<div class="flex-1 h-[1px] bg-n-slate-5" />
|
||||
<span class="text-sm font-medium text-n-slate-10">{{
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
|
||||
count: unusedAttributesCount,
|
||||
})
|
||||
}}</span>
|
||||
<div class="flex-1 h-[1px] bg-n-slate-5" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="!hasNoUnusedAttributes" class="relative">
|
||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
||||
|
||||
<div
|
||||
v-if="!hasNoUnusedAttributes"
|
||||
class="flex flex-col gap-3 px-4 py-4"
|
||||
:class="{ 'border-t border-n-weak': !hasNoUsedAttributes }"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
|
||||
>
|
||||
{{
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.UNUSED_ATTRIBUTES', {
|
||||
count: unusedAttributesCount,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
class="absolute i-lucide-search size-3.5 top-2.5 left-3 text-n-slate-10"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm border outline-none reset-base rounded-lg border-n-weak bg-n-alpha-black2 dark:bg-n-solid-2 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="filteredUnusedAttributes.length === 0 && !hasNoUnusedAttributes"
|
||||
v-if="filteredUnusedAttributes.length === 0"
|
||||
class="flex items-center justify-start h-11"
|
||||
>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.NO_ATTRIBUTES') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="!hasNoUnusedAttributes" class="flex flex-col gap-2">
|
||||
<div v-else class="flex flex-col">
|
||||
<ContactCustomAttributeItem
|
||||
v-for="attribute in filteredUnusedAttributes"
|
||||
:key="attribute.id"
|
||||
@@ -154,7 +166,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContactSidebarSection>
|
||||
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.ATTRIBUTES.EMPTY_STATE') }}
|
||||
</p>
|
||||
|
||||
+19
-13
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ConversationCard from 'dashboard/components-next/Conversation/ConversationCard/ConversationCard.vue';
|
||||
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -34,19 +35,24 @@ const contactConversations = computed(() =>
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="contactConversations.length > 0"
|
||||
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
|
||||
>
|
||||
<ConversationCard
|
||||
v-for="conversation in contactConversations"
|
||||
:key="conversation.id"
|
||||
:conversation="conversation"
|
||||
:contact="contactsById(conversation.meta.sender.id)"
|
||||
:state-inbox="stateInbox(conversation.inboxId)"
|
||||
:account-labels="accountLabelsValue"
|
||||
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
/>
|
||||
<div v-else-if="contactConversations.length > 0">
|
||||
<ContactSidebarSection
|
||||
:title="t('CONTACTS_LAYOUT.SIDEBAR.TABS.HISTORY')"
|
||||
:count="contactConversations.length"
|
||||
body-class="p-1.5 max-h-[60vh] overflow-y-auto"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<ConversationCard
|
||||
v-for="conversation in contactConversations"
|
||||
:key="conversation.id"
|
||||
:conversation="conversation"
|
||||
:contact="contactsById(conversation.meta.sender.id)"
|
||||
:state-inbox="stateInbox(conversation.inboxId)"
|
||||
:account-labels="accountLabelsValue"
|
||||
class="border-0 rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
|
||||
/>
|
||||
</div>
|
||||
</ContactSidebarSection>
|
||||
</div>
|
||||
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
|
||||
|
||||
@@ -12,9 +12,10 @@ import GalleryView from 'dashboard/components/widgets/conversation/components/Ga
|
||||
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
|
||||
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||
|
||||
const MEDIA_PEEK_LIMIT = 12;
|
||||
const FILES_PEEK_LIMIT = 6;
|
||||
const MEDIA_PEEK_LIMIT = 10;
|
||||
const FILES_PEEK_LIMIT = 4;
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -73,17 +74,21 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-6">
|
||||
<div>
|
||||
<div v-if="isFetching" class="flex justify-center p-3">
|
||||
<Spinner class="size-5" />
|
||||
</div>
|
||||
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
|
||||
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
|
||||
</p>
|
||||
<div v-else class="flex flex-col gap-5">
|
||||
<ContactSidebarSection
|
||||
v-else
|
||||
body-class="flex flex-col gap-5 px-4 py-4 max-h-[60vh] overflow-y-auto"
|
||||
>
|
||||
<Media
|
||||
:attachments="attachments"
|
||||
:peek-limit="MEDIA_PEEK_LIMIT"
|
||||
:columns="5"
|
||||
show-jump-to-message
|
||||
@select="onMediaSelect"
|
||||
@jump-to-message="onJumpToMessage"
|
||||
@@ -95,7 +100,7 @@ onMounted(() => {
|
||||
@select="onFileSelect"
|
||||
@jump-to-message="onJumpToMessage"
|
||||
/>
|
||||
</div>
|
||||
</ContactSidebarSection>
|
||||
<GalleryView
|
||||
v-if="showGallery && selectedAttachment"
|
||||
v-model:show="showGallery"
|
||||
|
||||
+36
-34
@@ -12,6 +12,7 @@ import { CONTACTS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactMergeForm from 'dashboard/components-next/Contacts/ContactsForm/ContactMergeForm.vue';
|
||||
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
@@ -103,43 +104,44 @@ const onMergeContacts = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-8 px-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h4 class="text-base text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
|
||||
</h4>
|
||||
<div>
|
||||
<ContactSidebarSection
|
||||
:title="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE')"
|
||||
body-class="flex flex-col gap-5 px-4 py-4"
|
||||
>
|
||||
<p class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<ContactMergeForm
|
||||
v-model:primary-contact-id="state.primaryContactId"
|
||||
:selected-contact="selectedContact"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:is-searching="isSearching"
|
||||
:has-error="!!v$.primaryContactId.$error"
|
||||
:error-message="
|
||||
v$.primaryContactId.$error
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
|
||||
: ''
|
||||
"
|
||||
@search="onContactSearch"
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="resetState"
|
||||
<ContactMergeForm
|
||||
v-model:primary-contact-id="state.primaryContactId"
|
||||
:selected-contact="selectedContact"
|
||||
:primary-contact-list="primaryContactList"
|
||||
:is-searching="isSearching"
|
||||
:has-error="!!v$.primaryContactId.$error"
|
||||
:error-message="
|
||||
v$.primaryContactId.$error
|
||||
? t('CONTACTS_LAYOUT.SIDEBAR.MERGE.PRIMARY_REQUIRED_ERROR')
|
||||
: ''
|
||||
"
|
||||
@search="onContactSearch"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
|
||||
class="w-full"
|
||||
:is-loading="isMergingContact"
|
||||
:disabled="isMergingContact"
|
||||
@click="onMergeContacts"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CANCEL')"
|
||||
class="bg-n-alpha-2 text-n-blue-11 hover:bg-n-alpha-3"
|
||||
@click="resetState"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.MERGE.BUTTONS.CONFIRM')"
|
||||
size="sm"
|
||||
:is-loading="isMergingContact"
|
||||
:disabled="isMergingContact"
|
||||
@click="onMergeContacts"
|
||||
/>
|
||||
</div>
|
||||
</ContactSidebarSection>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+73
-36
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { reactive, computed } from 'vue';
|
||||
import { reactive, computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'vue-router';
|
||||
@@ -8,6 +8,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||
import ContactNoteItem from './components/ContactNoteItem.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -25,6 +26,16 @@ const isFetchingNotes = computed(() => uiFlags.value.isFetching);
|
||||
const isCreatingNote = computed(() => uiFlags.value.isCreating);
|
||||
const notes = computed(() => notesByContact.value(route.params.contactId));
|
||||
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredNotes = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase();
|
||||
if (!query) return notes.value;
|
||||
return notes.value.filter(note =>
|
||||
(note.content || '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const getWrittenBy = note => {
|
||||
const isCurrentUser = note?.user?.id === currentUser.value.id;
|
||||
return isCurrentUser
|
||||
@@ -55,47 +66,73 @@ useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
|
||||
focus-on-mount
|
||||
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button
|
||||
variant="link"
|
||||
color="blue"
|
||||
size="sm"
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
|
||||
class="hover:no-underline"
|
||||
:is-loading="isCreatingNote"
|
||||
:disabled="!state.message || isCreatingNote"
|
||||
@click="onAdd(state.message)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Editor>
|
||||
<ContactSidebarSection
|
||||
:title="t('CONTACTS_LAYOUT.PROFILE.SECTIONS.NOTES')"
|
||||
:count="notes.length || null"
|
||||
body-class="p-0"
|
||||
>
|
||||
<div class="border-b border-n-weak">
|
||||
<Editor
|
||||
v-model="state.message"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
|
||||
class="[&>div]:!border-transparent [&>div]:!bg-transparent [&>div]:px-4 [&>div]:py-3"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button
|
||||
variant="link"
|
||||
color="blue"
|
||||
size="sm"
|
||||
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
|
||||
class="hover:no-underline"
|
||||
:is-loading="isCreatingNote"
|
||||
:disabled="!state.message || isCreatingNote"
|
||||
@click="onAdd(state.message)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Editor>
|
||||
</div>
|
||||
<div
|
||||
v-if="isFetchingNotes"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<div v-else-if="notes.length > 0">
|
||||
<ContactNoteItem
|
||||
v-for="note in notes"
|
||||
:key="note.id"
|
||||
class="mx-6 py-4"
|
||||
:note="note"
|
||||
:written-by="getWrittenBy(note)"
|
||||
allow-delete
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
|
||||
<template v-else-if="notes.length > 0">
|
||||
<div class="px-4 py-3 border-b border-n-weak">
|
||||
<div class="relative">
|
||||
<span
|
||||
class="absolute i-lucide-search size-3.5 top-2.5 left-3 text-n-slate-10"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SEARCH_PLACEHOLDER')"
|
||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm border outline-none reset-base rounded-lg border-n-weak bg-n-alpha-black2 dark:bg-n-solid-2 text-n-slate-12"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 max-h-[60vh] overflow-y-auto [&>div]:!border-b-0">
|
||||
<ContactNoteItem
|
||||
v-for="note in filteredNotes"
|
||||
:key="note.id"
|
||||
class="py-3.5"
|
||||
:note="note"
|
||||
:written-by="getWrittenBy(note)"
|
||||
allow-delete
|
||||
@delete="onDelete"
|
||||
/>
|
||||
<p
|
||||
v-if="filteredNotes.length === 0"
|
||||
class="py-6 text-sm leading-6 text-center text-n-slate-11"
|
||||
>
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_RESULTS') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="px-4 py-6 text-sm leading-6 text-center text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EMPTY_STATE') }}
|
||||
</p>
|
||||
</div>
|
||||
</ContactSidebarSection>
|
||||
</template>
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
count: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
bodyClass: {
|
||||
type: String,
|
||||
default: 'px-4 py-4',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex flex-col overflow-hidden border rounded-2xl border-n-weak bg-n-solid-1"
|
||||
>
|
||||
<header
|
||||
v-if="title || $slots.action"
|
||||
class="flex items-center justify-between gap-2 px-5 py-4 border-b border-n-weak"
|
||||
>
|
||||
<h4
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
|
||||
>
|
||||
{{ title }}
|
||||
<span
|
||||
v-if="count !== null && count !== ''"
|
||||
class="font-medium tracking-normal normal-case ms-1 text-n-slate-10"
|
||||
>
|
||||
{{ count }}
|
||||
</span>
|
||||
</h4>
|
||||
<slot name="action" />
|
||||
</header>
|
||||
<div :class="bodyClass">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -12,6 +12,15 @@ import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/Contac
|
||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import ContactStats from 'dashboard/components-next/Contacts/Pages/ContactStats.vue';
|
||||
import ContactSidebarSection from 'dashboard/components-next/Contacts/ContactsSidebar/ContactSidebarSection.vue';
|
||||
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
|
||||
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
|
||||
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
|
||||
import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
|
||||
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
|
||||
|
||||
const props = defineProps({
|
||||
selectedContact: {
|
||||
type: Object,
|
||||
@@ -38,6 +47,34 @@ const isFormInvalid = computed(() => contactsFormRef.value?.isFormInvalid);
|
||||
|
||||
const contactData = ref({});
|
||||
|
||||
const metaSeparator = '•';
|
||||
|
||||
const contactId = computed(() => props.selectedContact?.id);
|
||||
|
||||
const CONTACT_SECTIONS = [
|
||||
{ value: 'details', label: 'CONTACTS_LAYOUT.PROFILE.SECTIONS.DETAILS' },
|
||||
{ value: 'attributes', label: 'CONTACTS_LAYOUT.SIDEBAR.TABS.ATTRIBUTES' },
|
||||
{ value: 'history', label: 'CONTACTS_LAYOUT.PROFILE.STATS.CONVERSATIONS' },
|
||||
{ value: 'notes', label: 'CONTACTS_LAYOUT.PROFILE.STATS.NOTES' },
|
||||
{ value: 'media', label: 'CONTACTS_LAYOUT.PROFILE.STATS.FILES' },
|
||||
{ value: 'merge', label: 'CONTACTS_LAYOUT.SIDEBAR.TABS.MERGE' },
|
||||
];
|
||||
|
||||
const activeSection = ref('details');
|
||||
|
||||
const sectionTabs = computed(() =>
|
||||
CONTACT_SECTIONS.map(section => ({ label: t(section.label) }))
|
||||
);
|
||||
|
||||
const activeSectionIndex = computed(() =>
|
||||
CONTACT_SECTIONS.findIndex(section => section.value === activeSection.value)
|
||||
);
|
||||
|
||||
const handleSectionChange = tab => {
|
||||
const index = sectionTabs.value.findIndex(item => item.label === tab.label);
|
||||
if (index !== -1) activeSection.value = CONTACT_SECTIONS[index].value;
|
||||
};
|
||||
|
||||
const getInitialContactData = () => {
|
||||
if (!props.selectedContact) return {};
|
||||
return { ...props.selectedContact };
|
||||
@@ -45,6 +82,9 @@ const getInitialContactData = () => {
|
||||
|
||||
onMounted(() => {
|
||||
Object.assign(contactData.value, getInitialContactData());
|
||||
if (contactId.value) {
|
||||
store.dispatch('contacts/fetchAttachments', contactId.value);
|
||||
}
|
||||
});
|
||||
|
||||
const createdAt = computed(() => {
|
||||
@@ -121,8 +161,8 @@ const handleAvatarDelete = async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-8 pb-6">
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<div class="flex flex-col gap-8 pb-10">
|
||||
<div class="flex items-start gap-5 min-w-0">
|
||||
<Avatar
|
||||
:src="avatarSrc || ''"
|
||||
:name="selectedContact?.name || ''"
|
||||
@@ -131,73 +171,112 @@ const handleAvatarDelete = async () => {
|
||||
@upload="handleAvatarUpload"
|
||||
@delete="handleAvatarDelete"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
<div class="flex flex-col gap-2 min-w-0">
|
||||
<h1
|
||||
class="text-xl font-semibold leading-tight tracking-tight truncate text-n-slate-12"
|
||||
>
|
||||
{{ selectedContact?.name }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
</h1>
|
||||
<div
|
||||
class="flex flex-wrap items-center text-sm gap-x-2 gap-y-1 text-n-slate-11"
|
||||
>
|
||||
<span
|
||||
v-if="selectedContact?.identifier"
|
||||
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
<span class="i-ph-user-gear text-n-slate-10 size-4" />
|
||||
{{ selectedContact?.identifier }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
|
||||
<span
|
||||
v-if="selectedContact?.identifier"
|
||||
class="i-ph-activity text-n-slate-10 size-4"
|
||||
/>
|
||||
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
|
||||
•
|
||||
<span v-if="selectedContact?.identifier" class="text-n-slate-8">
|
||||
{{ metaSeparator }}
|
||||
</span>
|
||||
<span>
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
|
||||
</span>
|
||||
<span class="text-n-slate-8">{{ metaSeparator }}</span>
|
||||
<span>
|
||||
{{
|
||||
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
|
||||
t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
|
||||
date: lastActivityAt,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<ContactLabels :contact-id="selectedContact?.id" />
|
||||
</div>
|
||||
<ContactLabels :contact-id="selectedContact?.id" />
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-6">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
is-details-view
|
||||
@update="handleFormUpdate"
|
||||
|
||||
<ContactStats :contact-id="contactId" :last-seen="lastActivityAt" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<TabBar
|
||||
:tabs="sectionTabs"
|
||||
:initial-active-tab="activeSectionIndex"
|
||||
class="max-w-full bg-n-alpha-black2"
|
||||
@tab-changed="handleSectionChange"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="updateContact"
|
||||
/>
|
||||
</div>
|
||||
<Policy :permissions="['administrator']">
|
||||
<div
|
||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h6 class="text-base font-medium text-n-slate-12">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||
</h6>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
color="ruby"
|
||||
@click="openConfirmDeleteContactDialog"
|
||||
|
||||
<div>
|
||||
<template v-if="activeSection === 'details'">
|
||||
<div class="flex flex-col gap-4">
|
||||
<ContactSidebarSection
|
||||
:title="t('CONTACTS_LAYOUT.PROFILE.SECTIONS.DETAILS')"
|
||||
body-class="px-4 py-4"
|
||||
>
|
||||
<div class="flex flex-col items-start gap-6">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
is-details-view
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||
"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="updateContact"
|
||||
/>
|
||||
</div>
|
||||
</ContactSidebarSection>
|
||||
<Policy :permissions="['administrator']">
|
||||
<ContactSidebarSection
|
||||
:title="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
body-class="flex flex-col items-start gap-4 p-4"
|
||||
>
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||
</span>
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||
color="ruby"
|
||||
size="sm"
|
||||
@click="openConfirmDeleteContactDialog"
|
||||
/>
|
||||
</ContactSidebarSection>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="emit('goToContactsList')"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</template>
|
||||
<ContactCustomAttributes
|
||||
v-else-if="activeSection === 'attributes'"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
<ContactHistory v-else-if="activeSection === 'history'" />
|
||||
<ContactNotes v-else-if="activeSection === 'notes'" />
|
||||
<ContactMedia v-else-if="activeSection === 'media'" />
|
||||
<ContactMerge
|
||||
v-else-if="activeSection === 'merge'"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="emit('goToContactsList')"
|
||||
/>
|
||||
</div>
|
||||
<ConfirmContactDeleteDialog
|
||||
ref="confirmDeleteContactDialogRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="emit('goToContactsList')"
|
||||
/>
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const props = defineProps({
|
||||
contactId: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
lastSeen: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const conversationsByContact = useMapGetter(
|
||||
'contactConversations/getAllConversationsByContactId'
|
||||
);
|
||||
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
|
||||
const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
|
||||
|
||||
const statTiles = computed(() => [
|
||||
{
|
||||
key: 'conversations',
|
||||
label: t('CONTACTS_LAYOUT.PROFILE.STATS.CONVERSATIONS'),
|
||||
value: conversationsByContact.value(props.contactId)?.length ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'notes',
|
||||
label: t('CONTACTS_LAYOUT.PROFILE.STATS.NOTES'),
|
||||
value: notesByContact.value(props.contactId)?.length ?? 0,
|
||||
},
|
||||
{
|
||||
key: 'files',
|
||||
label: t('CONTACTS_LAYOUT.PROFILE.STATS.FILES'),
|
||||
value:
|
||||
attachmentsByContact.value(props.contactId)?.filter(a => a.data_url)
|
||||
?.length ?? 0,
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-2 overflow-hidden border sm:grid-cols-4 gap-px rounded-xl border-n-weak bg-n-weak"
|
||||
>
|
||||
<div
|
||||
v-for="tile in statTiles"
|
||||
:key="tile.key"
|
||||
class="flex flex-col gap-2 px-5 py-5 bg-n-solid-1"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
|
||||
>
|
||||
{{ tile.label }}
|
||||
</span>
|
||||
<span class="text-3xl font-semibold tabular-nums text-n-slate-12">
|
||||
{{ tile.value }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 px-5 py-5 bg-n-solid-1">
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wider uppercase text-n-slate-10"
|
||||
>
|
||||
{{ t('CONTACTS_LAYOUT.PROFILE.STATS.LAST_SEEN') }}
|
||||
</span>
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ lastSeen || t('CONTACTS_LAYOUT.PROFILE.STATS.NEVER') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -234,7 +234,6 @@ onMounted(() => resetContacts());
|
||||
ref="popoverRef"
|
||||
:align="align"
|
||||
:show-content-border="false"
|
||||
:close-on-scroll="false"
|
||||
@show="onPopoverShow"
|
||||
@hide="onPopoverHide"
|
||||
>
|
||||
|
||||
@@ -20,10 +20,21 @@ const props = defineProps({
|
||||
attachments: { type: Array, default: () => [] },
|
||||
peekLimit: { type: Number, default: 0 },
|
||||
showJumpToMessage: { type: Boolean, default: false },
|
||||
columns: { type: Number, default: 3 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'jumpToMessage']);
|
||||
|
||||
const gridColsClass = computed(
|
||||
() =>
|
||||
({
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
5: 'grid-cols-5',
|
||||
6: 'grid-cols-6',
|
||||
})[props.columns] || 'grid-cols-3'
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const mediaAttachments = computed(() =>
|
||||
@@ -177,7 +188,7 @@ const onDownloadFile = async attachment => {
|
||||
@click="showAll = !showAll"
|
||||
/>
|
||||
</header>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<div class="grid gap-2" :class="gridColsClass">
|
||||
<div
|
||||
v-for="(attachment, index) in visibleMedia"
|
||||
:key="attachment.id"
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
import {
|
||||
useBreakpoints,
|
||||
breakpointsTailwind,
|
||||
useEventListener,
|
||||
} from '@vueuse/core';
|
||||
import { useBreakpoints, breakpointsTailwind } from '@vueuse/core';
|
||||
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
|
||||
@@ -20,10 +16,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeOnScroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showContentBorder: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -49,12 +41,8 @@ const { fixedPosition, updatePosition } = useDropdownPosition(
|
||||
{ align: props.align }
|
||||
);
|
||||
|
||||
const SCROLL_CLOSE_THRESHOLD = 24;
|
||||
const triggerTopAtOpen = ref(0);
|
||||
|
||||
const show = async () => {
|
||||
isActive.value = true;
|
||||
triggerTopAtOpen.value = triggerRef.value?.getBoundingClientRect().top ?? 0;
|
||||
if (!isMobile.value) {
|
||||
await nextTick();
|
||||
updatePosition();
|
||||
@@ -68,22 +56,6 @@ const hide = () => {
|
||||
emit('hide');
|
||||
};
|
||||
|
||||
// The teleported popover tracks its trigger while ancestors scroll; allow
|
||||
// small drift (trackpad inertia), but close once the trigger moves further.
|
||||
useEventListener(
|
||||
window,
|
||||
'scroll',
|
||||
event => {
|
||||
if (!props.closeOnScroll || !showPopover.value) return;
|
||||
if (popoverRef.value?.contains(event.target)) return;
|
||||
const top = triggerRef.value?.getBoundingClientRect().top ?? 0;
|
||||
if (Math.abs(top - triggerTopAtOpen.value) > SCROLL_CLOSE_THRESHOLD) {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true }
|
||||
);
|
||||
|
||||
const toggle = async () => {
|
||||
if (isActive.value) hide();
|
||||
else await show();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||
|
||||
// Mock vue-i18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
@@ -94,32 +94,6 @@ describe('useAgentsList', () => {
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
|
||||
});
|
||||
|
||||
it('keeps nameless agent bots and applies a fallback label', () => {
|
||||
const namelessBot = {
|
||||
id: 91,
|
||||
name: null,
|
||||
assignee_type: 'AgentBot',
|
||||
availability_status: 'offline',
|
||||
};
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => [
|
||||
...allAgentsData,
|
||||
namelessBot,
|
||||
]),
|
||||
});
|
||||
|
||||
const { agentsList } = useAgentsList();
|
||||
// access the computed to trigger evaluation
|
||||
expect(agentsList.value).toBeDefined();
|
||||
|
||||
const passedAgents =
|
||||
agentHelper.getAgentsByUpdatedPresence.mock.calls[0][0];
|
||||
expect(passedAgents).toContainEqual({
|
||||
...namelessBot,
|
||||
name: '-',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import {
|
||||
getAgentsByUpdatedPresence,
|
||||
getSortedAgentsByAvailability,
|
||||
} from 'dashboard/helper/agentHelper';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
/**
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
@@ -53,11 +53,7 @@ export function useAgentsList(
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = (assignableAgents.value || []).map(agent =>
|
||||
!agent.name && agent.assignee_type === 'AgentBot'
|
||||
? { ...agent, name: '-' }
|
||||
: agent
|
||||
);
|
||||
const agents = assignableAgents.value || [];
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
|
||||
@@ -14,7 +14,6 @@ export const FEATURE_FLAGS = {
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
DATA_IMPORT: 'data_import',
|
||||
DELAYED_AUTOMATIONS: 'delayed_automations',
|
||||
API_AND_WEBHOOKS: 'api_and_webhooks',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
INTEGRATIONS: 'integrations',
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
export const getAgentsByAvailability = (agents, availability) => {
|
||||
return agents
|
||||
.filter(agent => agent.availability_status === availability)
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -208,12 +208,6 @@ 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);
|
||||
};
|
||||
|
||||
@@ -26,18 +26,6 @@ describe('agentHelper', () => {
|
||||
offlineAgentsData
|
||||
);
|
||||
});
|
||||
|
||||
it('does not throw when an agent has a null name', () => {
|
||||
const agents = [
|
||||
{ id: 1, name: null, availability_status: 'offline' },
|
||||
{ id: 2, name: 'Zoe', availability_status: 'offline' },
|
||||
];
|
||||
|
||||
expect(() => getAgentsByAvailability(agents, 'offline')).not.toThrow();
|
||||
expect(
|
||||
getAgentsByAvailability(agents, 'offline').map(agent => agent.id)
|
||||
).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSortedAgentsByAvailability', () => {
|
||||
|
||||
@@ -28,35 +28,6 @@
|
||||
"PLACEHOLDER": "Please select one",
|
||||
"ERROR": "Event is required"
|
||||
},
|
||||
"EXECUTE": {
|
||||
"LABEL": "Delayed execution",
|
||||
"AFTER_DELAY": "Run after",
|
||||
"UNITS": {
|
||||
"MINUTES": "Minutes",
|
||||
"HOURS": "Hours",
|
||||
"DAYS": "Days"
|
||||
},
|
||||
"ERROR": "Delay must be between 10 minutes and 30 days",
|
||||
"ENDS_IF_LABEL": "Won't run if",
|
||||
"ENDS_IF": {
|
||||
"STATUS": "the conversation's status changes, or its conditions no longer match.",
|
||||
"CUSTOMER_REPLY": "the customer replies, or the conditions no longer match.",
|
||||
"AGENT_REPLY": "an agent replies, or the conditions no longer match.",
|
||||
"GENERIC": "there is a new reply on the conversation, or the conditions no longer match."
|
||||
},
|
||||
"HELP_TEXT": "Only applies to conversations with activity after the rule is created."
|
||||
},
|
||||
"TRIGGER": {
|
||||
"LABEL": "Trigger",
|
||||
"WHEN_LABEL": "When",
|
||||
"STATUS_LABEL": "Status is",
|
||||
"INBOX_LABEL": "Inbox",
|
||||
"OPTIONS": {
|
||||
"CUSTOMER_UNRESPONSIVE": "Customer unresponsive",
|
||||
"AGENT_UNRESPONSIVE": "Teammate unresponsive",
|
||||
"CONVERSATION_STATUS": "Conversation in a status"
|
||||
}
|
||||
},
|
||||
"CONDITIONS": {
|
||||
"LABEL": "Conditions"
|
||||
},
|
||||
@@ -78,13 +49,7 @@
|
||||
"CREATED_ON": "Created on",
|
||||
"ACTIONS": "Actions"
|
||||
},
|
||||
"404": "No automation rules found",
|
||||
"SECTIONS": {
|
||||
"INSTANT": "Automations",
|
||||
"DELAYED": "Delayed execution"
|
||||
},
|
||||
"DELAY_BADGE": "Runs after {delay}",
|
||||
"DELAY_DISABLED_BANNER": "Delayed execution is disabled for this account. Delayed rules won't run until it is enabled again."
|
||||
"404": "No automation rules found"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Delete Automation Rule",
|
||||
|
||||
@@ -506,6 +506,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PROFILE": {
|
||||
"STATS": {
|
||||
"CONVERSATIONS": "Conversations",
|
||||
"NOTES": "Notes",
|
||||
"FILES": "Files",
|
||||
"LAST_SEEN": "Last seen",
|
||||
"NEVER": "Never"
|
||||
},
|
||||
"SECTIONS": {
|
||||
"DETAILS": "Details",
|
||||
"NOTES": "Notes"
|
||||
}
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"TABS": {
|
||||
"ATTRIBUTES": "Attributes",
|
||||
@@ -564,6 +577,8 @@
|
||||
},
|
||||
"NOTES": {
|
||||
"PLACEHOLDER": "Add a note",
|
||||
"SEARCH_PLACEHOLDER": "Search notes",
|
||||
"NO_RESULTS": "No notes match your search",
|
||||
"WROTE": "wrote",
|
||||
"YOU": "You",
|
||||
"SAVE": "Save note",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, ref } from 'vue';
|
||||
import { onMounted, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
@@ -8,12 +8,6 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
import ContactsDetailsLayout from 'dashboard/components-next/Contacts/ContactsDetailsLayout.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ContactDetails from 'dashboard/components-next/Contacts/Pages/ContactDetails.vue';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import ContactNotes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue';
|
||||
import ContactHistory from 'dashboard/components-next/Contacts/ContactsSidebar/ContactHistory.vue';
|
||||
import ContactMedia from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMedia.vue';
|
||||
import ContactMerge from 'dashboard/components-next/Contacts/ContactsSidebar/ContactMerge.vue';
|
||||
import ContactCustomAttributes from 'dashboard/components-next/Contacts/ContactsSidebar/ContactCustomAttributes.vue';
|
||||
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
@@ -22,9 +16,6 @@ const router = useRouter();
|
||||
const contact = useMapGetter('contacts/getContactById');
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const activeTab = ref('attributes');
|
||||
const contactMergeRef = ref(null);
|
||||
|
||||
const isFetchingItem = computed(() => uiFlags.value.isFetchingItem);
|
||||
const isMergingContact = computed(() => uiFlags.value.isMerging);
|
||||
const isUpdatingContact = computed(() => uiFlags.value.isUpdating);
|
||||
@@ -37,25 +28,6 @@ const showSpinner = computed(
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const CONTACT_TABS_OPTIONS = [
|
||||
{ key: 'ATTRIBUTES', value: 'attributes' },
|
||||
{ key: 'HISTORY', value: 'history' },
|
||||
{ key: 'NOTES', value: 'notes' },
|
||||
{ key: 'MEDIA', value: 'media' },
|
||||
{ key: 'MERGE', value: 'merge' },
|
||||
];
|
||||
|
||||
const tabs = computed(() => {
|
||||
return CONTACT_TABS_OPTIONS.map(tab => ({
|
||||
label: t(`CONTACTS_LAYOUT.SIDEBAR.TABS.${tab.key}`),
|
||||
value: tab.value,
|
||||
}));
|
||||
});
|
||||
|
||||
const activeTabIndex = computed(() => {
|
||||
return CONTACT_TABS_OPTIONS.findIndex(v => v.value === activeTab.value);
|
||||
});
|
||||
|
||||
const goToContactsList = () => {
|
||||
if (window.history.state?.back || window.history.length > 1) {
|
||||
router.back();
|
||||
@@ -74,10 +46,6 @@ const fetchActiveContact = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabChange = tab => {
|
||||
activeTab.value = tab.value;
|
||||
};
|
||||
|
||||
const fetchContactNotes = () => {
|
||||
const { contactId } = route.params;
|
||||
if (contactId) store.dispatch('contactNotes/get', { contactId });
|
||||
@@ -151,40 +119,6 @@ onMounted(() => {
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="goToContactsList"
|
||||
/>
|
||||
<template #sidebarHeader>
|
||||
<div class="px-6 pt-6 pb-3">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeTabIndex"
|
||||
class="w-full [&>button]:w-full bg-n-alpha-black2"
|
||||
@tab-changed="handleTabChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #sidebar>
|
||||
<div
|
||||
v-if="isFetchingItem"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<template v-else>
|
||||
<ContactCustomAttributes
|
||||
v-if="activeTab === 'attributes'"
|
||||
:selected-contact="selectedContact"
|
||||
/>
|
||||
<ContactNotes v-if="activeTab === 'notes'" />
|
||||
<ContactHistory v-if="activeTab === 'history'" />
|
||||
<ContactMedia v-if="activeTab === 'media'" />
|
||||
<ContactMerge
|
||||
v-if="activeTab === 'merge'"
|
||||
ref="contactMergeRef"
|
||||
:selected-contact="selectedContact"
|
||||
@go-to-contacts-list="goToContactsList"
|
||||
@reset-tab="handleTabChange(CONTACT_TABS_OPTIONS[0])"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</ContactsDetailsLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -135,7 +135,7 @@ onMounted(() => {
|
||||
<BaseTableCell class="max-w-0">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<Avatar
|
||||
:name="bot.name || ''"
|
||||
:name="bot.name"
|
||||
:src="bot.thumbnail"
|
||||
:size="40"
|
||||
class="flex-shrink-0"
|
||||
|
||||
@@ -10,7 +10,6 @@ const START_VALUE = {
|
||||
name: null,
|
||||
description: null,
|
||||
event_name: 'conversation_created',
|
||||
execution_delay: null,
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
|
||||
+78
-353
@@ -6,11 +6,6 @@ import { useOperators } from 'dashboard/components-next/filter/operators';
|
||||
import ConditionRow from 'dashboard/components-next/filter/ConditionRow.vue';
|
||||
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
|
||||
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
|
||||
import MultiSelect from 'dashboard/components-next/filter/inputs/MultiSelect.vue';
|
||||
import DurationInput from 'dashboard/components-next/input/DurationInput.vue';
|
||||
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import {
|
||||
generateAutomationPayload,
|
||||
@@ -19,7 +14,6 @@ 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({
|
||||
@@ -77,28 +71,6 @@ const INPUT_TYPE_MAP = {
|
||||
date: 'date',
|
||||
};
|
||||
|
||||
const DEFAULT_DELAY_MINUTES = 240; // 4 hours
|
||||
const MIN_DELAY_MINUTES = 10;
|
||||
const MAX_DELAY_MINUTES = 43200; // 30 days
|
||||
// A delayed rule is expressed as one meaningful trigger instead of a raw event + conditions. Each
|
||||
// trigger maps to the automation's event_name plus a preset condition: message_type for the two
|
||||
// unresponsive cases (reply-chase / awaiting-agent), or a chosen status for conversation_updated.
|
||||
const DELAYED_TRIGGERS = [
|
||||
{ key: 'conversation_status', eventName: 'conversation_updated' },
|
||||
{
|
||||
key: 'customer_unresponsive',
|
||||
eventName: 'message_created',
|
||||
messageType: 'outgoing',
|
||||
},
|
||||
{
|
||||
key: 'agent_unresponsive',
|
||||
eventName: 'message_created',
|
||||
messageType: 'incoming',
|
||||
},
|
||||
];
|
||||
const DEFAULT_TRIGGER = DELAYED_TRIGGERS[0].key;
|
||||
const DEFAULT_TRIGGER_STATUS = 'pending';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isCloudFeatureEnabled } = useAccount();
|
||||
const { operators } = useOperators();
|
||||
@@ -106,7 +78,6 @@ const { operators } = useOperators();
|
||||
const dialogRef = ref(null);
|
||||
const conditionsRef = useTemplateRef('conditionsRef');
|
||||
const errors = ref({});
|
||||
const isDelayed = ref(false);
|
||||
|
||||
const isEditMode = computed(() => props.mode === 'edit');
|
||||
|
||||
@@ -213,167 +184,6 @@ const hasActionErrors = computed(() =>
|
||||
Object.keys(errors.value).some(key => key.startsWith('action_'))
|
||||
);
|
||||
|
||||
const allowsDelayedExecution = computed(() =>
|
||||
isCloudFeatureEnabled(FEATURE_FLAGS.DELAYED_AUTOMATIONS)
|
||||
);
|
||||
|
||||
// Trigger controls for the delayed flow. They own event_name + conditions while the wait is on.
|
||||
// selectedTrigger / triggerStatus are plain values (FilterSelect); triggerInboxes is an array of
|
||||
// { id, name } (MultiSelect) — empty means the rule applies to every inbox.
|
||||
const selectedTrigger = ref(DEFAULT_TRIGGER);
|
||||
const triggerStatus = ref(DEFAULT_TRIGGER_STATUS);
|
||||
const triggerInboxes = ref([]);
|
||||
|
||||
const isStatusTrigger = computed(
|
||||
() => selectedTrigger.value === 'conversation_status'
|
||||
);
|
||||
|
||||
// FilterSelect expects { label, value }.
|
||||
const statusSelectOptions = computed(() =>
|
||||
(props.getConditionDropdownValues('status') || [])
|
||||
.filter(option => option.id !== 'all')
|
||||
.map(option => ({ value: option.id, label: option.name }))
|
||||
);
|
||||
|
||||
const triggerSelectOptions = computed(() =>
|
||||
DELAYED_TRIGGERS.map(trigger => ({
|
||||
value: trigger.key,
|
||||
label: t(
|
||||
`AUTOMATION.ADD.FORM.TRIGGER.OPTIONS.${trigger.key.toUpperCase()}`
|
||||
),
|
||||
}))
|
||||
);
|
||||
|
||||
// MultiSelect expects (and returns) { id, name } options.
|
||||
const inboxOptions = computed(
|
||||
() => props.getConditionDropdownValues('inbox_id') || []
|
||||
);
|
||||
|
||||
// What ends the wait, mirroring the backend episode that arms the rule. Shown to the user so
|
||||
// they can predict when the rule runs. Conversation rules key on status; message rules key on
|
||||
// the reply that ends the wait (customer reply for outgoing, agent reply for incoming).
|
||||
const waitEndsKey = computed(() => {
|
||||
if (eventName.value !== 'message_created') return 'STATUS';
|
||||
const messageType = (automation.value?.conditions || []).find(
|
||||
condition => condition.attribute_key === 'message_type'
|
||||
);
|
||||
const raw = Array.isArray(messageType?.values)
|
||||
? messageType.values[0]
|
||||
: messageType?.values;
|
||||
// Raw create-mode values are strings ('outgoing'); edit-mode values are option objects.
|
||||
const value = raw && typeof raw === 'object' ? raw.id : raw;
|
||||
if (value === 'outgoing') return 'CUSTOMER_REPLY';
|
||||
if (value === 'incoming') return 'AGENT_REPLY';
|
||||
return 'GENERIC';
|
||||
});
|
||||
|
||||
// DurationInput holds the wait in minutes and clamps to [MIN, MAX]; the unit is display-only.
|
||||
const delayMinutes = ref(DEFAULT_DELAY_MINUTES);
|
||||
const delayUnit = ref(DURATION_UNITS.HOURS);
|
||||
|
||||
const executionDelayInvalid = computed(
|
||||
() => isDelayed.value && !Number.isFinite(delayMinutes.value)
|
||||
);
|
||||
|
||||
// Show the wait in the largest whole unit (240 min → 4 hours). Passed in by open() rather than
|
||||
// read from `automation`, whose model prop only settles a tick later.
|
||||
const syncDelayFromDelay = delay => {
|
||||
isDelayed.value = Boolean(delay);
|
||||
const minutes = delay || DEFAULT_DELAY_MINUTES;
|
||||
if (minutes % 1440 === 0) delayUnit.value = DURATION_UNITS.DAYS;
|
||||
else if (minutes % 60 === 0) delayUnit.value = DURATION_UNITS.HOURS;
|
||||
else delayUnit.value = DURATION_UNITS.MINUTES;
|
||||
delayMinutes.value = minutes;
|
||||
};
|
||||
|
||||
watch([isDelayed, delayMinutes], () => {
|
||||
if (!automation.value || !allowsDelayedExecution.value) return;
|
||||
automation.value.execution_delay = isDelayed.value
|
||||
? delayMinutes.value
|
||||
: null;
|
||||
});
|
||||
|
||||
const buildTriggerCondition = (attributeKey, values) => ({
|
||||
attribute_key: attributeKey,
|
||||
filter_operator: 'equal_to',
|
||||
values,
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
});
|
||||
|
||||
// Write the selected trigger (plus optional inbox scope) onto the rule's event_name + conditions.
|
||||
const applyDelayedTrigger = () => {
|
||||
const trigger = DELAYED_TRIGGERS.find(
|
||||
item => item.key === selectedTrigger.value
|
||||
);
|
||||
if (!automation.value || !trigger) return;
|
||||
automation.value.event_name = trigger.eventName;
|
||||
const conditions = [
|
||||
trigger.messageType
|
||||
? buildTriggerCondition('message_type', trigger.messageType)
|
||||
: buildTriggerCondition('status', triggerStatus.value),
|
||||
];
|
||||
if (triggerInboxes.value.length) {
|
||||
conditions.push(
|
||||
buildTriggerCondition(
|
||||
'inbox_id',
|
||||
triggerInboxes.value.map(inbox => inbox.id)
|
||||
)
|
||||
);
|
||||
}
|
||||
automation.value.conditions = conditions;
|
||||
};
|
||||
|
||||
// A single value is a raw string in create mode and an option object ({ id }) after edit-mode
|
||||
// formatting; return its plain value either way.
|
||||
const rawConditionValue = condition => {
|
||||
const raw = Array.isArray(condition?.values)
|
||||
? condition.values[0]
|
||||
: condition?.values;
|
||||
return raw && typeof raw === 'object' ? raw.id : raw;
|
||||
};
|
||||
|
||||
// Populate the trigger controls from an existing delayed rule when editing.
|
||||
const hydrateTriggerFromAutomation = () => {
|
||||
const conditions = automation.value?.conditions || [];
|
||||
const byKey = key => conditions.find(c => c.attribute_key === key);
|
||||
const messageType = rawConditionValue(byKey('message_type'));
|
||||
if (messageType === 'incoming') selectedTrigger.value = 'agent_unresponsive';
|
||||
else if (messageType === 'outgoing')
|
||||
selectedTrigger.value = 'customer_unresponsive';
|
||||
else {
|
||||
selectedTrigger.value = 'conversation_status';
|
||||
triggerStatus.value =
|
||||
rawConditionValue(byKey('status')) || DEFAULT_TRIGGER_STATUS;
|
||||
}
|
||||
const inboxValues = byKey('inbox_id')?.values || [];
|
||||
const inboxIds = inboxValues.map(value =>
|
||||
value && typeof value === 'object' ? value.id : value
|
||||
);
|
||||
triggerInboxes.value = inboxOptions.value.filter(inbox =>
|
||||
inboxIds.includes(inbox.id)
|
||||
);
|
||||
};
|
||||
|
||||
// Turning the wait on (create) sets the default trigger's event + conditions.
|
||||
watch(isDelayed, delayed => {
|
||||
if (delayed && automation.value && !isEditMode.value) applyDelayedTrigger();
|
||||
});
|
||||
|
||||
// Any trigger-control change re-derives event_name + conditions. After hydration this simply
|
||||
// re-writes the same values, so it stays idempotent (no reference change → no loop).
|
||||
watch([selectedTrigger, triggerStatus, triggerInboxes], () => {
|
||||
if (isDelayed.value) applyDelayedTrigger();
|
||||
});
|
||||
|
||||
// Opening an existing delayed rule mirrors its event/conditions into the trigger controls.
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
if (isDelayed.value && automation.value) hydrateTriggerFromAutomation();
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => automation.value,
|
||||
() => {
|
||||
@@ -406,9 +216,8 @@ const syncCustomAttributeTypes = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const open = (executionDelay = null) => {
|
||||
const open = () => {
|
||||
resetValidation();
|
||||
syncDelayFromDelay(executionDelay);
|
||||
dialogRef.value?.open();
|
||||
};
|
||||
|
||||
@@ -421,13 +230,8 @@ 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);
|
||||
}
|
||||
};
|
||||
@@ -462,163 +266,84 @@ defineExpose({ open, close });
|
||||
:error="errors.description ? $t('AUTOMATION.ADD.FORM.DESC.ERROR') : ''"
|
||||
:placeholder="$t('AUTOMATION.ADD.FORM.DESC.PLACEHOLDER')"
|
||||
/>
|
||||
<!-- Wait Start (choose the delay first, then the trigger) -->
|
||||
<div v-if="allowsDelayedExecution" class="mb-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<label class="mb-0" :class="{ error: errors.execution_delay }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.LABEL') }}
|
||||
</label>
|
||||
<ToggleSwitch v-model="isDelayed" />
|
||||
</div>
|
||||
<div v-if="isDelayed" class="flex flex-wrap items-center gap-2 mt-2">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.AFTER_DELAY') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2 w-64">
|
||||
<DurationInput
|
||||
v-model="delayMinutes"
|
||||
v-model:unit="delayUnit"
|
||||
:min="MIN_DELAY_MINUTES"
|
||||
:max="MAX_DELAY_MINUTES"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="isDelayed && executionDelayInvalid"
|
||||
class="text-xs text-n-ruby-9"
|
||||
>
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ERROR') }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Wait End -->
|
||||
<!-- Delayed trigger: a curated event + condition, in place of raw Event/Conditions -->
|
||||
<div v-if="isDelayed" class="mb-6">
|
||||
<label class="mb-1">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.LABEL') }}
|
||||
</label>
|
||||
<div
|
||||
class="flex flex-col gap-3 p-4 outline outline-1 -outline-offset-1 rounded-xl outline-n-weak dark:outline-n-strong"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.WHEN_LABEL') }}
|
||||
</span>
|
||||
<FilterSelect
|
||||
v-model="selectedTrigger"
|
||||
:options="triggerSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="isStatusTrigger" class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.STATUS_LABEL') }}
|
||||
</span>
|
||||
<FilterSelect
|
||||
v-model="triggerStatus"
|
||||
:options="statusSelectOptions"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 min-h-8">
|
||||
<span class="w-20 shrink-0 text-sm text-n-slate-11">
|
||||
{{ $t('AUTOMATION.ADD.FORM.TRIGGER.INBOX_LABEL') }}
|
||||
</span>
|
||||
<MultiSelect v-model="triggerInboxes" :options="inboxOptions" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-n-slate-11 pt-2 mb-0">
|
||||
<span class="text-n-slate-12 font-medium">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF_LABEL') }}
|
||||
</span>
|
||||
{{ $t(`AUTOMATION.ADD.FORM.EXECUTE.ENDS_IF.${waitEndsKey}`) }}
|
||||
</p>
|
||||
<p class="text-xs text-n-slate-11 pt-1 mb-0">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EXECUTE.HELP_TEXT') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Instant flow: raw Event + Conditions -->
|
||||
<template v-else>
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange()"
|
||||
<div class="mb-6">
|
||||
<label :class="{ error: errors.event_name }">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
|
||||
<select
|
||||
v-model="automation.event_name"
|
||||
class="m-0"
|
||||
@change="onEventChange()"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
<option
|
||||
v-for="event in automationRuleEvents"
|
||||
:key="event.key"
|
||||
:value="event.key"
|
||||
>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="!isEditMode && hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
|
||||
:class="
|
||||
hasConditionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<template v-for="(condition, i) in automation.conditions" :key="i">
|
||||
<ConditionRow
|
||||
v-if="i === 0"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="
|
||||
automation.conditions[i].filter_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="
|
||||
automation.conditions[i].filter_operator
|
||||
"
|
||||
v-model:query-operator="
|
||||
automation.conditions[i - 1].query_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Conditions End -->
|
||||
</template>
|
||||
{{ event.value }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="errors.event_name" class="message">
|
||||
{{ $t('AUTOMATION.ADD.FORM.EVENT.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<p
|
||||
v-if="!isEditMode && hasAutomationMutated"
|
||||
class="text-xs text-right text-n-teal-10 pt-1"
|
||||
>
|
||||
{{ $t('AUTOMATION.FORM.RESET_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Conditions Start -->
|
||||
<section class="mb-5">
|
||||
<label>
|
||||
{{ $t('AUTOMATION.ADD.FORM.CONDITIONS.LABEL') }}
|
||||
</label>
|
||||
<ul
|
||||
class="grid gap-4 list-none p-3 mb-4 outline outline-1 rounded-xl -outline-offset-1"
|
||||
:class="
|
||||
hasConditionErrors
|
||||
? 'outline-n-ruby-5 bg-n-ruby-2/50'
|
||||
: 'outline-n-weak dark:outline-n-strong'
|
||||
"
|
||||
>
|
||||
<template v-for="(condition, i) in automation.conditions" :key="i">
|
||||
<ConditionRow
|
||||
v-if="i === 0"
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
:show-query-operator="false"
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
<ConditionRow
|
||||
v-else
|
||||
ref="conditionsRef"
|
||||
v-model:attribute-key="automation.conditions[i].attribute_key"
|
||||
v-model:filter-operator="automation.conditions[i].filter_operator"
|
||||
v-model:query-operator="
|
||||
automation.conditions[i - 1].query_operator
|
||||
"
|
||||
v-model:values="automation.conditions[i].values"
|
||||
:filter-types="filterTypes"
|
||||
show-query-operator
|
||||
@remove="removeFilter(i)"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<NextButton
|
||||
icon="i-lucide-plus"
|
||||
blue
|
||||
faded
|
||||
sm
|
||||
:label="$t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL')"
|
||||
@click="appendNewCondition"
|
||||
/>
|
||||
</div>
|
||||
</ul>
|
||||
</section>
|
||||
<!-- Conditions End -->
|
||||
<!-- Actions Start -->
|
||||
<section>
|
||||
<label>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<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';
|
||||
@@ -44,16 +43,6 @@ 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 }}
|
||||
|
||||
+17
-21
@@ -34,33 +34,29 @@ const {
|
||||
|
||||
const { formatAutomation } = useEditableAutomation();
|
||||
|
||||
const syncAutomationFromSelected = (source = props.selectedResponse) => {
|
||||
if (!source?.conditions) return;
|
||||
|
||||
manifestCustomAttributes();
|
||||
automation.value = formatAutomation(
|
||||
source,
|
||||
allCustomAttributes.value,
|
||||
automationTypes,
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
};
|
||||
|
||||
// Format from the rule passed to open(): the prop updates a tick later, so at open() time
|
||||
// automation still holds the previously selected rule (its execution_delay hydrates the form).
|
||||
const open = rule => {
|
||||
syncAutomationFromSelected(rule);
|
||||
formRef.value?.open(rule?.execution_delay);
|
||||
};
|
||||
const open = () => formRef.value?.open();
|
||||
const close = () => formRef.value?.close();
|
||||
|
||||
const onSave = (payload, mode) => {
|
||||
emit('saveAutomation', payload, mode);
|
||||
};
|
||||
|
||||
watch(() => props.selectedResponse, syncAutomationFromSelected, {
|
||||
immediate: true,
|
||||
});
|
||||
watch(
|
||||
() => props.selectedResponse,
|
||||
value => {
|
||||
if (!value?.conditions) return;
|
||||
|
||||
manifestCustomAttributes();
|
||||
|
||||
automation.value = formatAutomation(
|
||||
value,
|
||||
allCustomAttributes.value,
|
||||
automationTypes,
|
||||
AUTOMATION_ACTION_TYPES
|
||||
);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
@@ -35,31 +35,6 @@ 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);
|
||||
|
||||
@@ -77,14 +52,6 @@ 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');
|
||||
@@ -107,7 +74,7 @@ const hideAddPopup = () => {
|
||||
|
||||
const openEditPopup = response => {
|
||||
selectedAutomation.value = { ...response };
|
||||
editDialogRef.value?.open(response);
|
||||
editDialogRef.value?.open();
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
editDialogRef.value?.close();
|
||||
@@ -161,11 +128,11 @@ const submitAutomation = async (payload, mode) => {
|
||||
hideAddPopup();
|
||||
hideEditPopup();
|
||||
} catch (error) {
|
||||
const fallbackMessage =
|
||||
const errorMessage =
|
||||
mode === 'edit'
|
||||
? t('AUTOMATION.EDIT.API.ERROR_MESSAGE')
|
||||
: t('AUTOMATION.ADD.API.ERROR_MESSAGE');
|
||||
useAlert(error?.response?.data?.error || fallbackMessage);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
const toggleAutomation = async ({ id, name, status }) => {
|
||||
@@ -245,49 +212,25 @@ 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>
|
||||
<template v-if="filteredRecords.length">
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:key="section.key"
|
||||
class="mb-6 last:mb-0"
|
||||
>
|
||||
<h4
|
||||
v-if="hasDelayedRecords"
|
||||
class="mb-2 text-sm font-medium text-n-slate-11"
|
||||
>
|
||||
{{ section.label }}
|
||||
</h4>
|
||||
<BaseTable :headers="tableHeaders" :items="section.items">
|
||||
<template #row="{ items }">
|
||||
<AutomationRuleRow
|
||||
v-for="automation in items"
|
||||
:key="automation.id"
|
||||
:automation="automation"
|
||||
:loading="loading[automation.id]"
|
||||
@clone="cloneAutomation"
|
||||
@toggle="toggleAutomation"
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</div>
|
||||
</template>
|
||||
<BaseTable
|
||||
v-else
|
||||
:headers="tableHeaders"
|
||||
:items="[]"
|
||||
:items="filteredRecords"
|
||||
:no-data-message="
|
||||
searchQuery ? $t('AUTOMATION.NO_RESULTS') : $t('AUTOMATION.LIST.404')
|
||||
"
|
||||
>
|
||||
<template #row />
|
||||
<template #row="{ items }">
|
||||
<AutomationRuleRow
|
||||
v-for="automation in items"
|
||||
:key="automation.id"
|
||||
:automation="automation"
|
||||
:loading="loading[automation.id]"
|
||||
@clone="cloneAutomation"
|
||||
@toggle="toggleAutomation"
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -68,10 +68,6 @@ const isAgentBot = computed(
|
||||
() => props.selectedItem?.assignee_type === 'AgentBot'
|
||||
);
|
||||
|
||||
const selectedItemName = computed(() =>
|
||||
!props.selectedItem?.name && isAgentBot.value ? '-' : props.selectedItem?.name
|
||||
);
|
||||
|
||||
const selectedThumbnail = computed(
|
||||
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
|
||||
);
|
||||
@@ -99,16 +95,16 @@ const selectedThumbnail = computed(
|
||||
<h4
|
||||
v-else
|
||||
class="items-center overflow-hidden text-sm leading-tight whitespace-nowrap text-ellipsis text-n-slate-12"
|
||||
:title="selectedItemName"
|
||||
:title="selectedItem.name"
|
||||
>
|
||||
{{ selectedItemName }}
|
||||
{{ selectedItem.name }}
|
||||
</h4>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
|
||||
:src="selectedThumbnail"
|
||||
:status="selectedItem.availability_status"
|
||||
:name="selectedItemName"
|
||||
:name="selectedItem.name"
|
||||
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
|
||||
:size="24"
|
||||
hide-offline-status
|
||||
|
||||
@@ -53,9 +53,7 @@ export default {
|
||||
computed: {
|
||||
filteredOptions() {
|
||||
return this.options.filter(option => {
|
||||
return (option.name || '')
|
||||
.toLowerCase()
|
||||
.includes(this.search.toLowerCase());
|
||||
return option.name.toLowerCase().includes(this.search.toLowerCase());
|
||||
});
|
||||
},
|
||||
noResult() {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
class AutomationRules::ProcessPendingExecutionJob < ApplicationJob
|
||||
queue_as :medium
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(pending_execution)
|
||||
return if delayed_automations_disabled?
|
||||
# Account flag off pauses (not skips): leave the row pending so re-enabling resumes it.
|
||||
return unless pending_execution.account.feature_enabled?('delayed_automations')
|
||||
# Atomic claim: a duplicate enqueue (overlapping sweep or stale reclaim) loses here and returns.
|
||||
return unless pending_execution.claim!
|
||||
|
||||
skip_reason = skip_reason_for(pending_execution)
|
||||
return pending_execution.update!(status: :skipped, skip_reason: skip_reason) if skip_reason
|
||||
|
||||
execute(pending_execution)
|
||||
rescue StandardError => e
|
||||
# Row stays `processing`; the next sweep reclaims and retries it once the lock goes stale.
|
||||
ChatwootExceptionTracker.new(e, account: pending_execution.account).capture_exception
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_reason_for(pending_execution)
|
||||
return 'expired' if pending_execution.due_at < AutomationRulePendingExecution::DUE_WINDOW.ago
|
||||
|
||||
structural_skip_reason(pending_execution) || behavioral_skip_reason(pending_execution)
|
||||
end
|
||||
|
||||
def structural_skip_reason(pending_execution)
|
||||
rule = pending_execution.automation_rule
|
||||
return 'rule_inactive' if rule.nil? || !rule.active?
|
||||
return 'conversation_gone' if pending_execution.conversation.nil?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def behavioral_skip_reason(pending_execution)
|
||||
return 'episode_moved' unless pending_execution.episode_current?
|
||||
return AutomationRulePendingExecution::CONDITIONS_CHANGED_SKIP unless conditions_still_match?(pending_execution)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def conditions_still_match?(pending_execution)
|
||||
AutomationRules::ConditionsFilterService.new(
|
||||
pending_execution.automation_rule,
|
||||
pending_execution.conversation,
|
||||
{ message: pending_execution.message }
|
||||
).perform.present?
|
||||
end
|
||||
|
||||
def execute(pending_execution)
|
||||
AutomationRules::ActionService.new(
|
||||
pending_execution.automation_rule,
|
||||
pending_execution.account,
|
||||
pending_execution.conversation
|
||||
).perform
|
||||
pending_execution.update!(status: :executed)
|
||||
end
|
||||
|
||||
def delayed_automations_disabled?
|
||||
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
|
||||
end
|
||||
end
|
||||
@@ -1,11 +0,0 @@
|
||||
class AutomationRules::ResumePausedExecutionsJob < ApplicationJob
|
||||
# Enqueued the moment the account flag flips back on, ahead of the next sweep, so overdue rows
|
||||
# are rescheduled before that sweep's per-row jobs could mark them expired.
|
||||
queue_as :medium
|
||||
|
||||
discard_on ActiveJob::DeserializationError
|
||||
|
||||
def perform(account)
|
||||
AutomationRulePendingExecution.reschedule_paused(account)
|
||||
end
|
||||
end
|
||||
@@ -1,32 +0,0 @@
|
||||
class AutomationRules::TriggerPendingExecutionsJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
DEFAULT_SWEEP_LIMIT = 1000
|
||||
|
||||
def perform
|
||||
return if delayed_automations_disabled?
|
||||
|
||||
started_at = Time.current
|
||||
purged = AutomationRulePendingExecution.purge_terminal!
|
||||
|
||||
rows = AutomationRulePendingExecution.sweepable.for_enabled_accounts.order(:due_at).limit(sweep_limit).to_a
|
||||
rows.each { |row| AutomationRules::ProcessPendingExecutionJob.perform_later(row) }
|
||||
|
||||
log_summary(enqueued: rows.size, capped: rows.size >= sweep_limit, purged: purged, started_at: started_at)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delayed_automations_disabled?
|
||||
GlobalConfig.get('DISABLE_DELAYED_AUTOMATIONS')['DISABLE_DELAYED_AUTOMATIONS']
|
||||
end
|
||||
|
||||
def sweep_limit
|
||||
(InstallationConfig.find_by(name: 'AUTOMATION_PENDING_EXECUTIONS_SWEEP_LIMIT')&.value || DEFAULT_SWEEP_LIMIT).to_i
|
||||
end
|
||||
|
||||
def log_summary(enqueued:, capped:, purged:, started_at:)
|
||||
summary = { event: 'completed', enqueued: enqueued, capped: capped, purged: purged, duration_ms: ((Time.current - started_at) * 1000).round }
|
||||
Rails.logger.info("[AutomationRules::TriggerPendingExecutionsJob] #{summary.to_json}")
|
||||
end
|
||||
end
|
||||
@@ -19,9 +19,6 @@ class TriggerScheduledItemsJob < ApplicationJob
|
||||
|
||||
# Job to sync whatsapp templates
|
||||
Channels::Whatsapp::TemplatesSyncSchedulerJob.perform_later
|
||||
|
||||
# Job to trigger pending executions
|
||||
AutomationRules::TriggerPendingExecutionsJob.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -153,11 +153,11 @@ class Webhooks::WhatsappEventsJob < MutexApplicationJob
|
||||
end
|
||||
|
||||
def get_channel_from_wb_payload(wb_params)
|
||||
metadata = wb_params[:entry].first[:changes].first.dig(:value, :metadata) || {}
|
||||
Whatsapp::WebhookChannelFinderService.new(
|
||||
display_phone_number: metadata[:display_phone_number],
|
||||
phone_number_id: metadata[:phone_number_id]
|
||||
).perform
|
||||
phone_number = "+#{wb_params[:entry].first[:changes].first.dig(:value, :metadata, :display_phone_number)}"
|
||||
phone_number_id = wb_params[:entry].first[:changes].first.dig(:value, :metadata, :phone_number_id)
|
||||
channel = Channel::Whatsapp.find_by(phone_number: phone_number)
|
||||
# validate to ensure the phone number id matches the whatsapp channel
|
||||
return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class AutomationRuleListener < BaseListener
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, message.conversation,
|
||||
{ message: message, changed_attributes: changed_attributes }).perform
|
||||
execute_rule(rule, account, message.conversation, message: message) if conditions_match.present?
|
||||
::AutomationRules::ActionService.new(rule, account, message.conversation).perform if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -52,20 +52,7 @@ class AutomationRuleListener < BaseListener
|
||||
|
||||
rules.each do |rule|
|
||||
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
|
||||
execute_rule(rule, account, conversation) if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
# Delayed rules record a pending execution instead of acting; the sweep re-checks and
|
||||
# runs them at due time. Flag off means no arming and no immediate fallback — a delayed
|
||||
# message silently becoming instant is worse than skipping.
|
||||
def execute_rule(rule, account, conversation, message: nil)
|
||||
if rule.execution_delay.present?
|
||||
return unless account.feature_enabled?('delayed_automations')
|
||||
|
||||
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation, message: message)
|
||||
else
|
||||
::AutomationRules::ActionService.new(rule, account, conversation).perform
|
||||
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ class Account < ApplicationRecord
|
||||
has_many :articles, dependent: :destroy_async, class_name: '::Article'
|
||||
has_many :assignment_policies, dependent: :destroy_async
|
||||
has_many :automation_rules, dependent: :destroy_async
|
||||
has_many :automation_rule_pending_executions, dependent: :delete_all
|
||||
has_many :macros, dependent: :destroy_async
|
||||
has_many :campaigns, dependent: :destroy_async
|
||||
has_many :canned_responses, dependent: :destroy_async
|
||||
@@ -112,7 +111,6 @@ class Account < ApplicationRecord
|
||||
before_validation :validate_limit_keys
|
||||
after_create_commit :notify_creation
|
||||
after_update_commit :clear_unread_conversation_counts_cache, if: :saved_change_to_feature_conversation_unread_counts?
|
||||
after_update_commit :resume_delayed_automations, if: -> { saved_change_to_feature_delayed_automations? && feature_delayed_automations? }
|
||||
after_destroy :remove_account_sequences
|
||||
|
||||
def agents
|
||||
@@ -191,10 +189,6 @@ class Account < ApplicationRecord
|
||||
::Conversations::UnreadCounts::Store.clear_account!(id)
|
||||
end
|
||||
|
||||
def resume_delayed_automations
|
||||
AutomationRules::ResumePausedExecutionsJob.perform_later(self)
|
||||
end
|
||||
|
||||
trigger.after(:insert).for_each(:row) do
|
||||
"execute format('create sequence IF NOT EXISTS conv_dpid_seq_%s', NEW.id);"
|
||||
end
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
#
|
||||
# Table name: automation_rules
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# actions :jsonb not null
|
||||
# active :boolean default(TRUE), not null
|
||||
# conditions :jsonb not null
|
||||
# description :text
|
||||
# event_name :string not null
|
||||
# execution_delay :integer
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# actions :jsonb not null
|
||||
# active :boolean default(TRUE), not null
|
||||
# conditions :jsonb not null
|
||||
# description :text
|
||||
# event_name :string not null
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -22,13 +21,7 @@ class AutomationRule < ApplicationRecord
|
||||
include Rails.application.routes.url_helpers
|
||||
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
|
||||
|
||||
belongs_to :account
|
||||
has_many :pending_executions, class_name: 'AutomationRulePendingExecution', dependent: :delete_all
|
||||
has_many_attached :files
|
||||
|
||||
validate :json_conditions_format
|
||||
@@ -36,13 +29,8 @@ class AutomationRule < ApplicationRecord
|
||||
validate :query_operator_presence
|
||||
validate :query_operator_value
|
||||
validates :account_id, presence: true
|
||||
validates :execution_delay, numericality: { only_integer: true, in: EXECUTION_DELAY_RANGE }, allow_nil: true
|
||||
validate :execution_delay_supported_conditions
|
||||
validate :execution_delay_supported_event
|
||||
|
||||
after_update_commit :reauthorized!, if: -> { saved_change_to_conditions? }
|
||||
# Discard rows armed under the old definition; they re-arm on the next matching event.
|
||||
after_update :discard_stale_pending_executions, if: :execution_config_changed?
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
|
||||
@@ -107,34 +95,6 @@ class AutomationRule < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
# The fire-time re-check cannot reconstruct changed_attributes, so delayed rules
|
||||
# cannot use attribute_changed conditions.
|
||||
def execution_delay_supported_conditions
|
||||
return if execution_delay.blank? || conditions.blank?
|
||||
return if conditions.none? { |obj| obj['filter_operator'] == 'attribute_changed' }
|
||||
|
||||
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.
|
||||
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']) }
|
||||
|
||||
errors.add(:execution_delay, 'only supports status and inbox conditions for conversation-level events.')
|
||||
end
|
||||
|
||||
def execution_config_changed?
|
||||
saved_change_to_execution_delay? || saved_change_to_event_name? ||
|
||||
saved_change_to_conditions? || saved_change_to_actions?
|
||||
end
|
||||
|
||||
def discard_stale_pending_executions
|
||||
# armed = pending + stale processing, which the sweep would otherwise reclaim.
|
||||
pending_executions.armed.delete_all
|
||||
end
|
||||
|
||||
def validate_single_condition(condition)
|
||||
query_operator = condition['query_operator']
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: automation_rule_pending_executions
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# due_at :datetime not null
|
||||
# episode_key :string not null
|
||||
# skip_reason :string
|
||||
# status :integer default("pending"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# automation_rule_id :bigint not null
|
||||
# conversation_id :bigint not null
|
||||
# message_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_automation_rule_pending_executions_on_account_id (account_id)
|
||||
# index_automation_rule_pending_executions_on_automation_rule_id (automation_rule_id)
|
||||
# index_automation_rule_pending_executions_on_conversation_id (conversation_id)
|
||||
# index_automation_rule_pending_executions_on_status_and_due_at (status,due_at)
|
||||
# uniq_automation_pending_execution_episode (automation_rule_id,conversation_id,episode_key) UNIQUE
|
||||
#
|
||||
class AutomationRulePendingExecution < ApplicationRecord
|
||||
# Rows older than this never fire (bounds backlog replay after downtime).
|
||||
DUE_WINDOW = 3.days
|
||||
# A processing row whose lock is older than this is treated as abandoned and reclaimed.
|
||||
STALE_PROCESSING_TIMEOUT = 15.minutes
|
||||
# Terminal rows are purged after this to keep the table bounded.
|
||||
RETENTION_WINDOW = 30.days
|
||||
# Skip reason for a row cancelled only because conditions no longer matched at fire time; unlike
|
||||
# other terminal reasons, a later qualifying message can re-arm it (see .schedule).
|
||||
CONDITIONS_CHANGED_SKIP = 'conditions_changed'.freeze
|
||||
|
||||
belongs_to :automation_rule
|
||||
belongs_to :conversation
|
||||
belongs_to :account
|
||||
belongs_to :message, optional: true
|
||||
|
||||
enum status: { pending: 0, processing: 1, executed: 2, skipped: 3 }
|
||||
|
||||
# Rows a sweep should hand to a worker: due pending rows, plus processing rows whose lock went stale.
|
||||
scope :sweepable, lambda {
|
||||
pending.where(due_at: ..Time.current).or(processing.where(updated_at: ...STALE_PROCESSING_TIMEOUT.ago))
|
||||
}
|
||||
|
||||
# Non-terminal rows still bound to fire (a stale processing row is reclaimed by the sweep).
|
||||
scope :armed, -> { where(status: [statuses[:pending], statuses[:processing]]) }
|
||||
|
||||
# Excludes rows whose account paused delayed automations, so one disabled account's backlog
|
||||
# can't fill the sweep limit and starve enabled accounts (paused rows resume on re-enable).
|
||||
scope :for_enabled_accounts, -> { joins(:account).merge(Account.feature_delayed_automations) }
|
||||
|
||||
def self.schedule(rule:, conversation:, message: nil)
|
||||
key = arm_episode_key_for(conversation, message)
|
||||
anchor = arm_anchor_for(conversation, message)
|
||||
create!(
|
||||
automation_rule: rule, conversation: conversation, account_id: conversation.account_id,
|
||||
message_id: message&.id, episode_key: key, due_at: rule.execution_delay.minutes.since(anchor)
|
||||
)
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
rearm_or_advance_episode(rule, conversation, key, message, anchor)
|
||||
end
|
||||
|
||||
# The episode is already armed. Status episodes keep their first clock (a status change would
|
||||
# give a new key), so only message episodes advance or re-arm here.
|
||||
def self.rearm_or_advance_episode(rule, conversation, key, message, anchor)
|
||||
return unless message
|
||||
|
||||
row = find_by!(automation_rule_id: rule.id, conversation_id: conversation.id, episode_key: key)
|
||||
# Jobs can arrive out of order; only a strictly newer message advances or re-arms, so a late
|
||||
# older message can't pull due_at backwards and fire before the delay elapses.
|
||||
return unless message.id > row.message_id
|
||||
|
||||
due_at = rule.execution_delay.minutes.since(anchor)
|
||||
if row.condition_skipped?
|
||||
# A message episode key can recur (no new incoming reply) while conditions swing back into
|
||||
# match, so a later qualifying message re-arms the condition-only skip instead of dropping.
|
||||
row.update!(status: :pending, skip_reason: nil, due_at: due_at, message_id: message.id)
|
||||
elsif !row.terminal?
|
||||
# Track the newest qualifying message. Reply-chase advances due_at with each agent reply;
|
||||
# awaiting-agent keeps its first clock (its anchor is the stable waiting_since, so due_at is
|
||||
# unchanged). Re-anchoring a row still processing (its worker died mid-run) back to pending
|
||||
# also keeps a stale reclaim from firing the old clock instead of the latest one.
|
||||
row.update!(status: :pending, due_at: due_at, message_id: message.id)
|
||||
end
|
||||
end
|
||||
|
||||
# The wait is measured from when the qualifying event happened, not when this (possibly
|
||||
# backlogged or retried) listener runs, so a late dispatch still fires on schedule. Mirrors
|
||||
# the timestamps the episode keys track.
|
||||
def self.arm_anchor_for(conversation, message)
|
||||
if message.nil?
|
||||
conversation.status_changed_at.presence || conversation.created_at
|
||||
elsif message.incoming?
|
||||
conversation.waiting_since.presence || message.created_at
|
||||
else
|
||||
message.created_at
|
||||
end
|
||||
end
|
||||
|
||||
# waiting_since is written just after MESSAGE_CREATED dispatches, so it can still be nil when
|
||||
# an awaiting-agent episode arms. It becomes the starting message's created_at, so use that
|
||||
# here; the strict fire-time key (episode_key_for) then matches once waiting_since is settled.
|
||||
def self.arm_episode_key_for(conversation, message)
|
||||
return episode_key_for(conversation, message) unless message&.incoming? && conversation.waiting_since.blank?
|
||||
|
||||
"awaiting_agent:#{microsecond_stamp(message.created_at)}"
|
||||
end
|
||||
|
||||
# Microsecond integer, not a float: epoch seconds carry ~16 significant digits, past float64's
|
||||
# precision, so an in-memory timestamp (arm time) and its DB-reloaded value (fire time) would
|
||||
# round to different floats. strftime is exact on both. Sub-second distinguishes rapid episodes.
|
||||
def self.microsecond_stamp(time)
|
||||
time&.strftime('%s%6N') || '0'
|
||||
end
|
||||
|
||||
# Episode keys identify one qualifying stretch of conversation state; when the recomputed
|
||||
# key no longer matches, the episode ended and the pending action is cancelled at fire time.
|
||||
def self.episode_key_for(conversation, message)
|
||||
if message.nil?
|
||||
# Sub-second precision so a resolve→reopen inside one second still ends the episode.
|
||||
# Integer microseconds (not a float) so an in-memory arm and a DB-reloaded fire agree.
|
||||
"status:#{microsecond_stamp(conversation.status_changed_at.presence || conversation.created_at)}"
|
||||
elsif message.incoming?
|
||||
# waiting_since is cleared on agent/bot reply, so a reply invalidates this episode. Strict
|
||||
# here: at fire time a nil waiting_since means the agent replied (episode ended).
|
||||
"awaiting_agent:#{microsecond_stamp(conversation.waiting_since)}"
|
||||
else
|
||||
# A new customer message changes the max incoming id, invalidating this episode.
|
||||
"reply_chase:#{conversation.messages.incoming.maximum(:id) || 0}"
|
||||
end
|
||||
end
|
||||
|
||||
def self.purge_terminal!
|
||||
where(status: [statuses[:executed], statuses[:skipped]], updated_at: ...RETENTION_WINDOW.ago)
|
||||
.in_batches(of: 1000).delete_all
|
||||
end
|
||||
|
||||
# Rows that came due while an account had delayed automations paused would expire the moment
|
||||
# the sweep reaches them on resume. Reset their clock so pause/resume replays them (still
|
||||
# subject to the fire-time episode/condition re-checks) instead of silently dropping them.
|
||||
def self.reschedule_paused(account)
|
||||
overdue = pending.where(account_id: account.id, due_at: ...DUE_WINDOW.ago)
|
||||
overdue.find_each { |row| row.update!(due_at: Time.current) }
|
||||
end
|
||||
|
||||
# Atomic claim: only one worker can move a row into processing, so a row re-enqueued by an
|
||||
# overlapping sweep (or after a stale reclaim) cannot double-execute. Refreshing updated_at
|
||||
# renews the lock, keeping the row out of the stale window while this worker holds it.
|
||||
def claim!
|
||||
with_lock do
|
||||
next false unless claimable?
|
||||
|
||||
update!(status: :processing, updated_at: Time.current)
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def episode_current?
|
||||
self.class.episode_key_for(conversation, message) == episode_key
|
||||
end
|
||||
|
||||
def condition_skipped?
|
||||
skipped? && skip_reason == CONDITIONS_CHANGED_SKIP
|
||||
end
|
||||
|
||||
def terminal?
|
||||
executed? || skipped?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def claimable?
|
||||
# due_at guard: a reply-chase reschedule can push due_at forward after this row was enqueued;
|
||||
# such a row must wait for a later sweep instead of firing early.
|
||||
(pending? && due_at <= Time.current) || (processing? && updated_at < STALE_PROCESSING_TIMEOUT.ago)
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,6 @@
|
||||
# priority :integer
|
||||
# snoozed_until :datetime
|
||||
# status :integer default("open"), not null
|
||||
# status_changed_at :datetime
|
||||
# uuid :uuid not null
|
||||
# waiting_since :datetime
|
||||
# created_at :datetime not null
|
||||
@@ -125,10 +124,8 @@ class Conversation < ApplicationRecord
|
||||
has_many :notifications, as: :primary_actor, dependent: :destroy_async
|
||||
has_many :attachments, through: :messages
|
||||
has_many :reporting_events, dependent: :destroy_async
|
||||
has_many :automation_rule_pending_executions, dependent: :delete_all
|
||||
|
||||
before_save :ensure_snooze_until_reset
|
||||
before_save :set_status_changed_at
|
||||
before_create :determine_conversation_status
|
||||
before_create :ensure_waiting_since
|
||||
|
||||
@@ -275,10 +272,6 @@ class Conversation < ApplicationRecord
|
||||
self.snoozed_until = nil unless snoozed?
|
||||
end
|
||||
|
||||
def set_status_changed_at
|
||||
self.status_changed_at = Time.current if new_record? || status_changed?
|
||||
end
|
||||
|
||||
def ensure_waiting_since
|
||||
self.waiting_since = created_at
|
||||
end
|
||||
|
||||
@@ -6,10 +6,12 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
return send_template_message if template_params.present?
|
||||
return send_session_message if message.conversation.can_reply?
|
||||
|
||||
message.update!(status: :failed, external_error: I18n.t('errors.whatsapp.message_outside_messaging_window'))
|
||||
should_send_template_message = template_params.present? || !message.conversation.can_reply?
|
||||
if should_send_template_message
|
||||
send_template_message
|
||||
else
|
||||
send_session_message
|
||||
end
|
||||
end
|
||||
|
||||
def send_template_message
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Resolves the WhatsApp channel for an inbound WhatsApp Cloud webhook. Meta's
|
||||
# display_phone_number can arrive formatted or in a country-specific variant (e.g. Brazil
|
||||
# omits the mobile 9, Argentina adds a digit after the country code), so we try the
|
||||
# raw digits first and then a normalized fallback, accepting only a candidate whose
|
||||
# phone_number_id matches.
|
||||
class Whatsapp::WebhookChannelFinderService
|
||||
def initialize(display_phone_number:, phone_number_id:)
|
||||
@display_phone_number = display_phone_number
|
||||
@phone_number_id = phone_number_id
|
||||
end
|
||||
|
||||
def perform
|
||||
return if digits.blank?
|
||||
|
||||
candidates = [
|
||||
Channel::Whatsapp.find_by(phone_number: "+#{digits}"),
|
||||
channel_by_normalized_number
|
||||
]
|
||||
candidates.compact.find { |channel| channel.provider_config['phone_number_id'] == @phone_number_id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def digits
|
||||
@digits ||= @display_phone_number.to_s.gsub(/[^0-9]/, '')
|
||||
end
|
||||
|
||||
def channel_by_normalized_number
|
||||
normalizer = Whatsapp::PhoneNumberNormalizationService::NORMALIZERS
|
||||
.lazy.map(&:new).find { |n| n.handles_country?(digits) }
|
||||
return unless normalizer
|
||||
|
||||
Channel::Whatsapp.find_by(phone_number: "+#{normalizer.normalize(digits)}")
|
||||
end
|
||||
end
|
||||
@@ -7,5 +7,4 @@ json.conditions automation_rule.conditions
|
||||
json.actions automation_rule.actions
|
||||
json.created_on automation_rule.created_at.to_i
|
||||
json.active automation_rule.active?
|
||||
json.execution_delay automation_rule.execution_delay
|
||||
json.files automation_rule.file_base_data if automation_rule.files.any?
|
||||
|
||||
@@ -268,8 +268,3 @@
|
||||
display_name: WhatsApp Embedded Signup Flow
|
||||
enabled: false
|
||||
column: feature_flags_ext_1
|
||||
- name: delayed_automations
|
||||
display_name: Delayed Automations
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
column: feature_flags_ext_1
|
||||
|
||||
@@ -567,10 +567,3 @@
|
||||
value: 'https://us.cloud.langfuse.com'
|
||||
locked: false
|
||||
## ---- End of LLM Observability ---- ##
|
||||
|
||||
- name: DISABLE_DELAYED_AUTOMATIONS
|
||||
display_title: 'Disable delayed automations'
|
||||
description: 'Emergency stop for delayed automation rules: halts the pending-execution sweep and per-row execution within one tick'
|
||||
value: false
|
||||
locked: false
|
||||
type: boolean
|
||||
|
||||
@@ -154,7 +154,6 @@ en:
|
||||
invalid_token_permissions: 'The access token does not have the required permissions for WhatsApp.'
|
||||
phone_info_fetch_failed: 'Failed to fetch phone number information. Please try again.'
|
||||
phone_number_already_exists: 'Channel already exists for this phone number: %{phone_number}, please contact support if the error persists'
|
||||
message_outside_messaging_window: 'Message not sent because the WhatsApp 24-hour customer service window is closed and no template parameters were provided. Send an approved template message instead.'
|
||||
reauthorization:
|
||||
generic: 'Failed to reauthorize WhatsApp. Please try again.'
|
||||
not_supported: 'Reauthorization is not supported for this type of WhatsApp channel.'
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddExecutionDelayToAutomationRules < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :automation_rules, :execution_delay, :integer
|
||||
end
|
||||
end
|
||||
@@ -1,5 +0,0 @@
|
||||
class AddStatusChangedAtToConversations < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :conversations, :status_changed_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -1,21 +0,0 @@
|
||||
class CreateAutomationRulePendingExecutions < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
create_table :automation_rule_pending_executions do |t|
|
||||
t.references :automation_rule, null: false
|
||||
t.references :conversation, null: false
|
||||
t.references :account, null: false
|
||||
t.bigint :message_id
|
||||
t.datetime :due_at, null: false
|
||||
t.string :episode_key, null: false
|
||||
t.integer :status, null: false, default: 0
|
||||
t.string :skip_reason
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :automation_rule_pending_executions, [:status, :due_at]
|
||||
add_index :automation_rule_pending_executions,
|
||||
[:automation_rule_id, :conversation_id, :episode_key],
|
||||
unique: true, name: 'uniq_automation_pending_execution_episode'
|
||||
end
|
||||
end
|
||||
@@ -277,24 +277,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.index ["user_id", "user_type"], name: "user_index"
|
||||
end
|
||||
|
||||
create_table "automation_rule_pending_executions", force: :cascade do |t|
|
||||
t.bigint "automation_rule_id", null: false
|
||||
t.bigint "conversation_id", null: false
|
||||
t.bigint "account_id", null: false
|
||||
t.bigint "message_id"
|
||||
t.datetime "due_at", null: false
|
||||
t.string "episode_key", null: false
|
||||
t.integer "status", default: 0, null: false
|
||||
t.string "skip_reason"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id"], name: "index_automation_rule_pending_executions_on_account_id"
|
||||
t.index ["automation_rule_id", "conversation_id", "episode_key"], name: "uniq_automation_pending_execution_episode", unique: true
|
||||
t.index ["automation_rule_id"], name: "index_automation_rule_pending_executions_on_automation_rule_id"
|
||||
t.index ["conversation_id"], name: "index_automation_rule_pending_executions_on_conversation_id"
|
||||
t.index ["status", "due_at"], name: "index_automation_rule_pending_executions_on_status_and_due_at"
|
||||
end
|
||||
|
||||
create_table "automation_rules", force: :cascade do |t|
|
||||
t.bigint "account_id", null: false
|
||||
t.string "name", null: false
|
||||
@@ -305,7 +287,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "active", default: true, null: false
|
||||
t.integer "execution_delay"
|
||||
t.index ["account_id"], name: "index_automation_rules_on_account_id"
|
||||
end
|
||||
|
||||
@@ -807,7 +788,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
|
||||
t.datetime "waiting_since"
|
||||
t.text "cached_label_list"
|
||||
t.bigint "assignee_agent_bot_id"
|
||||
t.datetime "status_changed_at"
|
||||
t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true
|
||||
t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id"
|
||||
t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx"
|
||||
|
||||
@@ -451,71 +451,4 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'execution_delay handling' do
|
||||
let(:delayed_rule_params) do
|
||||
{
|
||||
name: 'Delayed rule',
|
||||
event_name: 'conversation_updated',
|
||||
execution_delay: 240,
|
||||
conditions: [{ attribute_key: 'status', filter_operator: 'equal_to', values: ['pending'], query_operator: nil }],
|
||||
actions: [{ action_name: 'add_label', action_params: ['stale'] }]
|
||||
}
|
||||
end
|
||||
|
||||
context 'when the delayed_automations feature is enabled' do
|
||||
before { account.enable_features!('delayed_automations') }
|
||||
|
||||
it 'persists and serializes execution_delay' do
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: delayed_rule_params
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = JSON.parse(response.body, symbolize_names: true)
|
||||
expect(body[:execution_delay]).to eq(240)
|
||||
expect(account.automation_rules.last.execution_delay).to eq(240)
|
||||
end
|
||||
|
||||
it 'copies execution_delay on clone' do
|
||||
automation_rule = create(:automation_rule, account: account, execution_delay: 240)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}/clone",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.automation_rules.last.execution_delay).to eq(240)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the delayed_automations feature is disabled' do
|
||||
it 'rejects a payload carrying execution_delay with 422' do
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: delayed_rule_params
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(account.automation_rules.count).to eq(0)
|
||||
end
|
||||
|
||||
it 'still accepts payloads without execution_delay' do
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules",
|
||||
headers: administrator.create_new_auth_token,
|
||||
params: delayed_rule_params.except(:execution_delay)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.automation_rules.last.execution_delay).to be_nil
|
||||
end
|
||||
|
||||
it 'strips execution_delay when cloning an existing delayed rule' do
|
||||
automation_rule = create(:automation_rule, account: account, execution_delay: 240)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}/clone",
|
||||
headers: administrator.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(account.automation_rules.last.execution_delay).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
FactoryBot.define do
|
||||
factory :automation_rule_pending_execution do
|
||||
account
|
||||
automation_rule { association :automation_rule, account: account }
|
||||
conversation { association :conversation, account: account }
|
||||
# Derive from production so the row is episode_current (matches the conversation's status).
|
||||
episode_key { AutomationRulePendingExecution.episode_key_for(conversation, nil) }
|
||||
due_at { 1.hour.from_now }
|
||||
status { :pending }
|
||||
end
|
||||
end
|
||||
@@ -1,155 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRules::ProcessPendingExecutionJob do
|
||||
subject(:job) { described_class.new }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:rule) do
|
||||
create(:automation_rule, account: account, event_name: 'conversation_updated', execution_delay: 60,
|
||||
conditions: [{ 'values' => ['pending'], 'attribute_key' => 'status', 'query_operator' => nil,
|
||||
'filter_operator' => 'equal_to' }],
|
||||
actions: [{ 'action_name' => 'add_label', 'action_params' => ['stale'] }])
|
||||
end
|
||||
let(:pending_execution) do
|
||||
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation)
|
||||
# The sweep only enqueues due rows, so make it due before the job runs.
|
||||
AutomationRulePendingExecution.last.tap { |row| row.update!(due_at: 1.minute.ago) }
|
||||
end
|
||||
|
||||
before do
|
||||
GlobalConfig.clear_cache
|
||||
account.enable_features!('delayed_automations')
|
||||
end
|
||||
|
||||
it 'runs the actions and marks the row executed when every guard passes' do
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_executed
|
||||
expect(conversation.reload.label_list).to include('stale')
|
||||
end
|
||||
|
||||
it 'skips with rule_inactive when the rule was disabled' do
|
||||
rule.update!(active: false)
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_skipped
|
||||
expect(pending_execution.skip_reason).to eq('rule_inactive')
|
||||
expect(conversation.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'pauses (keeps pending) while the account flag is off, then fires when re-enabled' do
|
||||
account.disable_features!('delayed_automations')
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_pending
|
||||
expect(conversation.reload.label_list).to be_empty
|
||||
|
||||
account.enable_features!('delayed_automations')
|
||||
described_class.new.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_executed
|
||||
expect(conversation.reload.label_list).to include('stale')
|
||||
end
|
||||
|
||||
it 'skips with episode_moved when the conversation left the armed status' do
|
||||
pending_execution
|
||||
conversation.update!(status: :resolved)
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_skipped
|
||||
expect(pending_execution.skip_reason).to eq('episode_moved')
|
||||
expect(conversation.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'skips with conditions_changed when the conversation drifts but the episode is intact' do
|
||||
# A message_created (reply-chase) rule whose extra condition is the conversation status.
|
||||
message_rule = create(:automation_rule, account: account, event_name: 'message_created', execution_delay: 60,
|
||||
conditions: [{ 'values' => ['pending'], 'attribute_key' => 'status',
|
||||
'query_operator' => nil, 'filter_operator' => 'equal_to' }],
|
||||
actions: [{ 'action_name' => 'add_label', 'action_params' => ['stale'] }])
|
||||
agent_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
AutomationRulePendingExecution.schedule(rule: message_rule, conversation: conversation, message: agent_reply)
|
||||
row = AutomationRulePendingExecution.last.tap { |r| r.update!(due_at: 1.minute.ago) }
|
||||
|
||||
# Status change fails the condition but leaves the reply_chase episode (max incoming id) intact.
|
||||
conversation.update!(status: :open)
|
||||
job.perform(row)
|
||||
|
||||
expect(row.reload).to be_skipped
|
||||
expect(row.skip_reason).to eq('conditions_changed')
|
||||
end
|
||||
|
||||
it 'skips with expired when the row is past the due window' do
|
||||
pending_execution.update!(due_at: 4.days.ago)
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_skipped
|
||||
expect(pending_execution.skip_reason).to eq('expired')
|
||||
expect(conversation.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'leaves the row untouched without executing when the kill switch is set' do
|
||||
create(:installation_config, name: 'DISABLE_DELAYED_AUTOMATIONS', serialized_value: { value: true }.with_indifferent_access)
|
||||
GlobalConfig.clear_cache
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_pending
|
||||
expect(conversation.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'runs the actions once when the same row is processed twice concurrently' do
|
||||
allow(AutomationRules::ActionService).to receive(:new).and_call_original
|
||||
duplicate = AutomationRulePendingExecution.find(pending_execution.id)
|
||||
|
||||
job.perform(pending_execution.reload)
|
||||
described_class.new.perform(duplicate)
|
||||
|
||||
expect(AutomationRules::ActionService).to have_received(:new).once
|
||||
expect(pending_execution.reload).to be_executed
|
||||
end
|
||||
|
||||
it 'leaves the row processing and reports the error when an action blows up' do
|
||||
action_service = instance_double(AutomationRules::ActionService)
|
||||
allow(AutomationRules::ActionService).to receive(:new).and_return(action_service)
|
||||
allow(action_service).to receive(:perform).and_raise(StandardError, 'boom')
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
job.perform(pending_execution.reload)
|
||||
|
||||
expect(pending_execution.reload).to be_processing
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
|
||||
it 'sends the follow-up exactly once for the reply-chase story' do
|
||||
message_rule = create(:automation_rule, account: account, event_name: 'message_created', execution_delay: 60,
|
||||
conditions: [{ 'values' => ['outgoing'], 'attribute_key' => 'message_type',
|
||||
'query_operator' => nil, 'filter_operator' => 'equal_to' }],
|
||||
actions: [{ 'action_name' => 'send_message', 'action_params' => ['Just checking in'] }])
|
||||
agent_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
AutomationRulePendingExecution.schedule(rule: message_rule, conversation: conversation, message: agent_reply)
|
||||
row = AutomationRulePendingExecution.last.tap { |r| r.update!(due_at: 1.minute.ago) }
|
||||
|
||||
job.perform(row.reload)
|
||||
|
||||
expect(row.reload).to be_executed
|
||||
expect(conversation.messages.outgoing.where(content: 'Just checking in').count).to eq(1)
|
||||
end
|
||||
|
||||
it 'cancels the follow-up when the customer replied before it was due' do
|
||||
message_rule = create(:automation_rule, account: account, event_name: 'message_created', execution_delay: 60,
|
||||
conditions: [{ 'values' => ['outgoing'], 'attribute_key' => 'message_type',
|
||||
'query_operator' => nil, 'filter_operator' => 'equal_to' }],
|
||||
actions: [{ 'action_name' => 'send_message', 'action_params' => ['Just checking in'] }])
|
||||
agent_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
AutomationRulePendingExecution.schedule(rule: message_rule, conversation: conversation, message: agent_reply)
|
||||
row = AutomationRulePendingExecution.last.tap { |r| r.update!(due_at: 1.minute.ago) }
|
||||
|
||||
create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
job.perform(row.reload)
|
||||
|
||||
expect(row.reload).to be_skipped
|
||||
expect(row.skip_reason).to eq('episode_moved')
|
||||
expect(conversation.messages.outgoing.pluck(:content)).not_to include('Just checking in')
|
||||
end
|
||||
end
|
||||
@@ -1,62 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRules::TriggerPendingExecutionsJob do
|
||||
subject(:job) { described_class.new }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
|
||||
before do
|
||||
GlobalConfig.clear_cache
|
||||
account.enable_features!('delayed_automations')
|
||||
end
|
||||
|
||||
it 'enqueues a per-row job for due pending rows but not future ones' do
|
||||
due_row = create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
|
||||
future_row = create(:automation_rule_pending_execution, account: account, due_at: 1.hour.from_now)
|
||||
|
||||
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).exactly(:once)
|
||||
expect(AutomationRules::ProcessPendingExecutionJob).to have_been_enqueued.with(due_row)
|
||||
expect(AutomationRules::ProcessPendingExecutionJob).not_to have_been_enqueued.with(future_row)
|
||||
end
|
||||
|
||||
it 're-enqueues stale processing rows so they get retried' do
|
||||
stale_row = travel_to(20.minutes.ago) do
|
||||
create(:automation_rule_pending_execution, account: account, conversation: conversation, status: :processing, due_at: 19.minutes.from_now)
|
||||
end
|
||||
|
||||
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).with(stale_row)
|
||||
end
|
||||
|
||||
it 'caps enqueues at the configured sweep limit' do
|
||||
create(:installation_config, name: 'AUTOMATION_PENDING_EXECUTIONS_SWEEP_LIMIT', serialized_value: { value: 1 }.with_indifferent_access)
|
||||
create_list(:automation_rule_pending_execution, 2, account: account, due_at: 1.minute.ago)
|
||||
|
||||
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).exactly(:once)
|
||||
end
|
||||
|
||||
it 'purges terminal rows past the retention window' do
|
||||
old_row = travel_to(31.days.ago) { create(:automation_rule_pending_execution, account: account, status: :executed) }
|
||||
|
||||
job.perform
|
||||
|
||||
expect { old_row.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it 'skips rows for accounts with delayed automations disabled so they cannot starve others' do
|
||||
enabled_row = create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
|
||||
disabled_account = create(:account) # delayed_automations off by default
|
||||
create(:automation_rule_pending_execution, account: disabled_account, due_at: 2.minutes.ago)
|
||||
|
||||
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).exactly(:once)
|
||||
expect(AutomationRules::ProcessPendingExecutionJob).to have_been_enqueued.with(enabled_row)
|
||||
end
|
||||
|
||||
it 'does nothing when the kill switch is set' do
|
||||
create(:installation_config, name: 'DISABLE_DELAYED_AUTOMATIONS', serialized_value: { value: true }.with_indifferent_access)
|
||||
GlobalConfig.clear_cache
|
||||
create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
|
||||
|
||||
expect { job.perform }.not_to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob)
|
||||
end
|
||||
end
|
||||
@@ -345,94 +345,6 @@ RSpec.describe Webhooks::WhatsappEventsJob do
|
||||
end.not_to change(Conversation, :count)
|
||||
end
|
||||
|
||||
it 'finds channel using normalized Brazil phone number when display_phone_number is missing the 9 digit' do
|
||||
brazil_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: brazil_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '554199887766'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: brazil_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'finds channel using normalized Argentina phone number when display_phone_number has extra 9 digit' do
|
||||
argentina_channel = create(:channel_whatsapp, phone_number: '+541112345678', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: argentina_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '5491112345678'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: argentina_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'finds channel when display_phone_number contains formatting characters' do
|
||||
formatted_channel = create(:channel_whatsapp, phone_number: '+14155552671', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: formatted_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '+1 415-555-2671'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: formatted_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'prefers the phone_number_id match when a raw display_phone_number collision exists' do
|
||||
normalized_channel = create(:channel_whatsapp, phone_number: '+5541999887766', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false)
|
||||
create(:channel_whatsapp, phone_number: '+554199887766', provider: 'whatsapp_cloud',
|
||||
sync_templates: false, validate_provider_config: false).tap do |raw_channel|
|
||||
raw_channel.update!(provider_config: raw_channel.provider_config.merge('phone_number_id' => 'other-id'))
|
||||
end
|
||||
wb_params = {
|
||||
object: 'whatsapp_business_account',
|
||||
entry: [{
|
||||
changes: [{
|
||||
value: {
|
||||
metadata: {
|
||||
phone_number_id: normalized_channel.provider_config['phone_number_id'],
|
||||
display_phone_number: '554199887766'
|
||||
}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}
|
||||
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
|
||||
expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).with(inbox: normalized_channel.inbox, params: wb_params)
|
||||
job.perform_now(wb_params)
|
||||
end
|
||||
|
||||
it 'will not enque Whatsapp::IncomingMessageWhatsappCloudService when invalid phone number id' do
|
||||
other_channel = create(:channel_whatsapp, phone_number: '+1987654', provider: 'whatsapp_cloud', sync_templates: false,
|
||||
validate_provider_config: false)
|
||||
|
||||
@@ -247,37 +247,4 @@ describe AutomationRuleListener do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'delayed rules' do
|
||||
let!(:automation_rule) { create(:automation_rule, event_name: 'conversation_updated', account: account, execution_delay: 60) }
|
||||
let(:event) do
|
||||
Events::Base.new('conversation_updated', Time.zone.now, { conversation: conversation, changed_attributes: {} })
|
||||
end
|
||||
|
||||
before { allow(condition_match).to receive(:present?).and_return(true) }
|
||||
|
||||
context 'when the delayed_automations feature is enabled' do
|
||||
before { account.enable_features!('delayed_automations') }
|
||||
|
||||
it 'records a pending execution instead of running actions' do
|
||||
expect { listener.conversation_updated(event) }.to change(AutomationRulePendingExecution, :count).by(1)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new)
|
||||
expect(AutomationRulePendingExecution.last.due_at).to be_within(5.seconds).of(60.minutes.from_now)
|
||||
end
|
||||
|
||||
it 'still runs rules without a delay immediately' do
|
||||
automation_rule.update!(execution_delay: nil)
|
||||
|
||||
expect { listener.conversation_updated(event) }.not_to change(AutomationRulePendingExecution, :count)
|
||||
expect(AutomationRules::ActionService).to have_received(:new).with(automation_rule, account, conversation)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the delayed_automations feature is disabled' do
|
||||
it 'neither arms a pending execution nor falls back to immediate execution' do
|
||||
expect { listener.conversation_updated(event) }.not_to change(AutomationRulePendingExecution, :count)
|
||||
expect(AutomationRules::ActionService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -112,21 +112,6 @@ RSpec.describe Account do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'resuming delayed automations' do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
it 'enqueues the resume job when delayed_automations is turned back on' do
|
||||
expect { account.enable_features!('delayed_automations') }
|
||||
.to have_enqueued_job(AutomationRules::ResumePausedExecutionsJob).with(account)
|
||||
end
|
||||
|
||||
it 'does not enqueue the resume job when the flag is turned off' do
|
||||
account.enable_features!('delayed_automations')
|
||||
expect { account.disable_features!('delayed_automations') }
|
||||
.not_to have_enqueued_job(AutomationRules::ResumePausedExecutionsJob)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'feature flag columns' do
|
||||
let(:account) { described_class.new(name: 'Test Account') }
|
||||
|
||||
@@ -137,8 +122,7 @@ RSpec.describe Account do
|
||||
feature_data_import: 1 << 1,
|
||||
feature_api_and_webhooks: 1 << 2,
|
||||
feature_whatsapp_reconfigure: 1 << 3,
|
||||
feature_whatsapp_embedded_signup_inbox_creation: 1 << 4,
|
||||
feature_delayed_automations: 1 << 5
|
||||
feature_whatsapp_embedded_signup_inbox_creation: 1 << 4
|
||||
)
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1)
|
||||
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2)
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AutomationRulePendingExecution do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:rule) do
|
||||
create(:automation_rule, account: account, event_name: 'conversation_updated', execution_delay: 60,
|
||||
actions: [{ 'action_name' => 'add_label', 'action_params' => ['stale'] }])
|
||||
end
|
||||
|
||||
describe '.episode_key_for' do
|
||||
it 'derives status episodes from status_changed_at' do
|
||||
expect(described_class.episode_key_for(conversation, nil)).to eq("status:#{conversation.status_changed_at.strftime('%s%6N')}")
|
||||
end
|
||||
|
||||
it 'matches between an in-memory arm and a DB-reloaded fire (no float rounding drift)' do
|
||||
conversation.status_changed_at = Time.zone.at(1_784_102_080.844761923r)
|
||||
arm_key = described_class.episode_key_for(conversation, nil)
|
||||
conversation.save!
|
||||
expect(arm_key).to eq(described_class.episode_key_for(conversation.reload, nil))
|
||||
end
|
||||
|
||||
it 'falls back to created_at when status_changed_at is blank' do
|
||||
conversation.update!(status_changed_at: nil)
|
||||
expect(described_class.episode_key_for(conversation.reload, nil)).to eq("status:#{conversation.created_at.strftime('%s%6N')}")
|
||||
end
|
||||
|
||||
it 'derives awaiting_agent episodes from waiting_since (sub-second) for incoming messages' do
|
||||
message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
expect(described_class.episode_key_for(conversation.reload, message)).to eq("awaiting_agent:#{conversation.waiting_since.strftime('%s%6N')}")
|
||||
end
|
||||
|
||||
it 'distinguishes two waiting periods that fall within the same second' do
|
||||
message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
first_key = described_class.episode_key_for(conversation.reload, message)
|
||||
|
||||
# Agent replies then customer re-waits within the same second: keys must differ.
|
||||
conversation.update!(waiting_since: conversation.waiting_since + 0.4)
|
||||
expect(described_class.episode_key_for(conversation.reload, message)).not_to eq(first_key)
|
||||
end
|
||||
|
||||
it 'arms an awaiting_agent episode from the message created_at when waiting_since is not yet written' do
|
||||
message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
# Simulate the race where the listener arms before update_waiting_since commits.
|
||||
conversation.update!(waiting_since: nil)
|
||||
armed_key = described_class.arm_episode_key_for(conversation.reload, message)
|
||||
|
||||
# Once waiting_since settles to the message's created_at, the strict fire-time key matches.
|
||||
conversation.update!(waiting_since: message.created_at)
|
||||
expect(armed_key).to eq("awaiting_agent:#{message.created_at.strftime('%s%6N')}")
|
||||
expect(armed_key).to eq(described_class.episode_key_for(conversation.reload, message))
|
||||
end
|
||||
|
||||
it 'derives reply_chase episodes from the max incoming message id for outgoing messages' do
|
||||
incoming = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
outgoing = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
expect(described_class.episode_key_for(conversation.reload, outgoing)).to eq("reply_chase:#{incoming.id}")
|
||||
end
|
||||
|
||||
it 'uses 0 for reply_chase when there is no incoming message' do
|
||||
outgoing = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
expect(described_class.episode_key_for(conversation.reload, outgoing)).to eq('reply_chase:0')
|
||||
end
|
||||
end
|
||||
|
||||
describe '.schedule' do
|
||||
it 'creates a pending row due after the rule delay' do
|
||||
described_class.schedule(rule: rule, conversation: conversation)
|
||||
|
||||
row = described_class.last
|
||||
expect(row).to have_attributes(account_id: account.id, conversation_id: conversation.id, status: 'pending')
|
||||
expect(row.due_at).to be_within(5.seconds).of(60.minutes.from_now)
|
||||
end
|
||||
|
||||
it 'anchors due_at to the event time, not when a backlogged listener runs' do
|
||||
conversation.update!(status_changed_at: 30.minutes.ago)
|
||||
described_class.schedule(rule: rule, conversation: conversation)
|
||||
|
||||
# A 60-minute rule on a status that changed 30 minutes ago is already 30 minutes into its wait.
|
||||
expect(described_class.last.due_at).to be_within(5.seconds).of(30.minutes.from_now)
|
||||
end
|
||||
|
||||
it 'does not reset the clock for a repeated status episode' do
|
||||
described_class.schedule(rule: rule, conversation: conversation)
|
||||
original_due_at = described_class.last.due_at
|
||||
|
||||
travel_to(30.minutes.from_now) { described_class.schedule(rule: rule, conversation: conversation) }
|
||||
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last.due_at).to be_within(1.second).of(original_due_at)
|
||||
end
|
||||
|
||||
it 'moves the clock and anchor for a repeated reply_chase episode' do
|
||||
first_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: first_reply)
|
||||
|
||||
travel_to(30.minutes.from_now) do
|
||||
second_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: second_reply)
|
||||
|
||||
# due_at is re-anchored to the new reply's created_at, not the original schedule time.
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last.message_id).to eq(second_reply.id)
|
||||
expect(described_class.last.due_at).to be_within(5.seconds).of(60.minutes.from_now)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not let a late older reply move the reply_chase clock backwards' do
|
||||
older_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
newer_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
|
||||
# The newer reply's job runs first and arms the episode.
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: newer_reply)
|
||||
armed_due_at = described_class.last.due_at
|
||||
|
||||
# The older reply's job arrives late; it must not pull the clock or message_id back.
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: older_reply)
|
||||
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last.message_id).to eq(newer_reply.id)
|
||||
expect(described_class.last.due_at).to eq(armed_due_at)
|
||||
end
|
||||
|
||||
it 'does not re-arm an executed reply_chase episode' do
|
||||
reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: reply)
|
||||
described_class.last.update!(status: :executed)
|
||||
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: reply)
|
||||
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last).to be_executed
|
||||
end
|
||||
|
||||
it 're-arms a condition-skipped episode when a newer qualifying message arrives' do
|
||||
first_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: first_reply)
|
||||
described_class.last.update!(status: :skipped, skip_reason: 'conditions_changed')
|
||||
|
||||
second_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: second_reply)
|
||||
|
||||
row = described_class.last
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(row).to be_pending
|
||||
expect(row.skip_reason).to be_nil
|
||||
expect(row.message_id).to eq(second_reply.id)
|
||||
end
|
||||
|
||||
it 're-anchors a reply_chase row stuck in processing when a newer reply arrives' do
|
||||
first_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: first_reply)
|
||||
# The worker claimed the row and then died, leaving it in processing with the old clock.
|
||||
described_class.last.update!(status: :processing)
|
||||
|
||||
travel_to(30.minutes.from_now) do
|
||||
second_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: second_reply)
|
||||
|
||||
row = described_class.last
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(row).to be_pending
|
||||
expect(row.message_id).to eq(second_reply.id)
|
||||
expect(row.due_at).to be_within(5.seconds).of(60.minutes.from_now)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not re-arm an episode skipped for a non-condition reason' do
|
||||
reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: reply)
|
||||
described_class.last.update!(status: :skipped, skip_reason: 'episode_moved')
|
||||
|
||||
newer_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: newer_reply)
|
||||
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last).to be_skipped
|
||||
end
|
||||
|
||||
it 'keeps the awaiting_agent clock but tracks the newest incoming message' do
|
||||
first_message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: first_message)
|
||||
original_due_at = described_class.last.due_at
|
||||
|
||||
second_message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
travel_to(30.minutes.from_now) { described_class.schedule(rule: rule, conversation: conversation, message: second_message) }
|
||||
|
||||
row = described_class.last
|
||||
expect(described_class.count).to eq(1)
|
||||
# The wait still counts from waiting_since, so the clock is unchanged...
|
||||
expect(row.due_at).to be_within(1.second).of(original_due_at)
|
||||
# ...but the row tracks the newest qualifying message, not the stale first one.
|
||||
expect(row.message_id).to eq(second_message.id)
|
||||
end
|
||||
|
||||
it 'keeps the newest message when an older incoming collision arrives last' do
|
||||
first_message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: first_message)
|
||||
|
||||
older = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
newer = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
|
||||
# The newer message re-arms first; a late older-message collision must not overwrite it.
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: newer)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: older)
|
||||
|
||||
expect(described_class.count).to eq(1)
|
||||
expect(described_class.last.message_id).to eq(newer.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#episode_current?' do
|
||||
it 'is true while the conversation stays in the armed status' do
|
||||
described_class.schedule(rule: rule, conversation: conversation)
|
||||
expect(described_class.last.episode_current?).to be(true)
|
||||
end
|
||||
|
||||
it 'is false after a status transition' do
|
||||
described_class.schedule(rule: rule, conversation: conversation)
|
||||
conversation.update!(status: :resolved)
|
||||
expect(described_class.last.reload.episode_current?).to be(false)
|
||||
end
|
||||
|
||||
it 'is false for awaiting_agent episodes once the agent replies (waiting_since cleared)' do
|
||||
message = create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: message)
|
||||
|
||||
conversation.update!(waiting_since: nil)
|
||||
expect(described_class.last.episode_current?).to be(false)
|
||||
end
|
||||
|
||||
it 'is false for reply_chase episodes once the customer replies' do
|
||||
reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
|
||||
described_class.schedule(rule: rule, conversation: conversation, message: reply)
|
||||
|
||||
create(:message, conversation: conversation, account: account, message_type: :incoming)
|
||||
expect(described_class.last.episode_current?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#claim!' do
|
||||
let(:row) { create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago) }
|
||||
|
||||
it 'claims a pending row exactly once so a duplicate enqueue cannot double-fire' do
|
||||
expect(row.claim!).to be(true)
|
||||
expect(row.reload).to be_processing
|
||||
expect(described_class.find(row.id).claim!).to be(false)
|
||||
end
|
||||
|
||||
it 'does not claim a row whose due_at was pushed into the future (reply-chase reschedule)' do
|
||||
row.update!(due_at: 1.hour.from_now)
|
||||
expect(row.claim!).to be(false)
|
||||
end
|
||||
|
||||
it 'does not claim terminal rows' do
|
||||
row.update!(status: :executed)
|
||||
expect(row.claim!).to be(false)
|
||||
end
|
||||
|
||||
it 'reclaims a processing row only after its lock goes stale' do
|
||||
row.update!(status: :processing)
|
||||
expect(row.claim!).to be(false)
|
||||
|
||||
travel_to(20.minutes.from_now) { expect(row.claim!).to be(true) }
|
||||
end
|
||||
end
|
||||
|
||||
describe '.sweepable' do
|
||||
it 'selects due pending rows and stale processing rows, but not future or fresh ones' do
|
||||
due = create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
|
||||
create(:automation_rule_pending_execution, account: account, due_at: 1.hour.from_now)
|
||||
create(:automation_rule_pending_execution, account: account, status: :processing)
|
||||
stale = travel_to(20.minutes.ago) do
|
||||
create(:automation_rule_pending_execution, account: account, conversation: conversation, status: :processing)
|
||||
end
|
||||
|
||||
expect(described_class.sweepable).to contain_exactly(due, stale)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.purge_terminal!' do
|
||||
it 'deletes terminal rows past the retention window and keeps everything else' do
|
||||
old_executed = travel_to(31.days.ago) { create(:automation_rule_pending_execution, account: account, status: :executed) }
|
||||
recent_skipped = create(:automation_rule_pending_execution, account: account, status: :skipped)
|
||||
pending = create(:automation_rule_pending_execution, account: account, conversation: conversation)
|
||||
|
||||
described_class.purge_terminal!
|
||||
|
||||
expect(described_class.pluck(:id)).to contain_exactly(recent_skipped.id, pending.id)
|
||||
expect { old_executed.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.reschedule_paused' do
|
||||
it 'resets rows overdue past the window so a resumed account replays them instead of expiring' do
|
||||
expired = create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 5.days.ago)
|
||||
within_window = create(:automation_rule_pending_execution, account: account, due_at: 2.days.ago)
|
||||
|
||||
described_class.reschedule_paused(account)
|
||||
|
||||
expect(expired.reload.due_at).to be_within(5.seconds).of(Time.current)
|
||||
expect(within_window.reload.due_at).to be_within(5.seconds).of(2.days.ago)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -137,118 +137,4 @@ RSpec.describe AutomationRule do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'execution_delay validations' do
|
||||
let(:rule) { build(:automation_rule, account: create(:account)) }
|
||||
|
||||
it 'allows nil (immediate execution)' do
|
||||
rule.execution_delay = nil
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
|
||||
it 'allows delays between 10 minutes and 30 days' do
|
||||
rule.execution_delay = 240
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
|
||||
it 'rejects delays below 10 minutes' do
|
||||
rule.execution_delay = 5
|
||||
expect(rule).not_to be_valid
|
||||
expect(rule.errors[:execution_delay]).to be_present
|
||||
end
|
||||
|
||||
it 'rejects delays above 30 days' do
|
||||
rule.execution_delay = 43_201
|
||||
expect(rule).not_to be_valid
|
||||
end
|
||||
|
||||
it 'rejects non-integer delays' do
|
||||
rule.execution_delay = 10.5
|
||||
expect(rule).not_to be_valid
|
||||
end
|
||||
|
||||
it 'rejects a delay combined with an attribute_changed condition' do
|
||||
rule.execution_delay = 60
|
||||
rule.conditions = [{ 'attribute_key' => 'status', 'filter_operator' => 'attribute_changed',
|
||||
'values' => { 'from' => ['open'], 'to' => ['pending'] }, 'query_operator' => nil }]
|
||||
expect(rule).not_to be_valid
|
||||
expect(rule.errors[:execution_delay]).to include('cannot be used with attribute_changed conditions.')
|
||||
end
|
||||
|
||||
it 'rejects a delayed conversation-level rule with a mutable non-status condition' do
|
||||
rule.event_name = 'conversation_updated'
|
||||
rule.execution_delay = 60
|
||||
rule.conditions = [{ 'attribute_key' => 'priority', 'filter_operator' => 'equal_to', 'values' => ['urgent'], 'query_operator' => nil }]
|
||||
expect(rule).not_to be_valid
|
||||
expect(rule.errors[:execution_delay]).to include('only supports status and inbox conditions for conversation-level events.')
|
||||
end
|
||||
|
||||
it 'allows a delayed conversation-level rule with only status conditions' do
|
||||
rule.event_name = 'conversation_updated'
|
||||
rule.execution_delay = 60
|
||||
rule.conditions = [{ 'attribute_key' => 'status', 'filter_operator' => 'equal_to', 'values' => ['pending'], 'query_operator' => nil }]
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
|
||||
it 'allows a delayed conversation_created rule (arms on creation)' do
|
||||
rule.event_name = 'conversation_created'
|
||||
rule.execution_delay = 10
|
||||
rule.conditions = [{ 'attribute_key' => 'status', 'filter_operator' => 'equal_to', 'values' => ['open'], 'query_operator' => nil }]
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
|
||||
it 'allows a delayed conversation-level rule scoped by status and inbox (immutable)' do
|
||||
rule.event_name = 'conversation_updated'
|
||||
rule.execution_delay = 60
|
||||
rule.conditions = [{ 'attribute_key' => 'status', 'filter_operator' => 'equal_to', 'values' => ['pending'], 'query_operator' => 'AND' },
|
||||
{ 'attribute_key' => 'inbox_id', 'filter_operator' => 'equal_to', 'values' => [1], 'query_operator' => nil }]
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
|
||||
it 'allows a delayed message_created rule with a non-status condition' do
|
||||
rule.event_name = 'message_created'
|
||||
rule.execution_delay = 60
|
||||
rule.conditions = [{ 'attribute_key' => 'message_type', 'filter_operator' => 'equal_to', 'values' => ['outgoing'], 'query_operator' => nil }]
|
||||
expect(rule).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe 'discarding stale pending executions on edit' do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account, status: :pending) }
|
||||
let(:status_condition) { { 'attribute_key' => 'status', 'filter_operator' => 'equal_to', 'values' => ['pending'], 'query_operator' => nil } }
|
||||
let(:rule) do
|
||||
create(:automation_rule, account: account, event_name: 'conversation_updated', execution_delay: 60,
|
||||
conditions: [status_condition], actions: [{ 'action_name' => 'add_label', 'action_params' => ['stale'] }])
|
||||
end
|
||||
|
||||
before { AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation) }
|
||||
|
||||
it 'discards armed rows when the actions change' do
|
||||
rule.update!(actions: [{ 'action_name' => 'add_label', 'action_params' => ['urgent'] }])
|
||||
expect(rule.pending_executions.pending).to be_empty
|
||||
end
|
||||
|
||||
it 'discards armed rows when the delay changes' do
|
||||
rule.update!(execution_delay: 120)
|
||||
expect(rule.pending_executions.pending).to be_empty
|
||||
end
|
||||
|
||||
it 'discards a stale processing row that the sweep would otherwise reclaim' do
|
||||
rule.pending_executions.first.update!(status: :processing)
|
||||
rule.update!(actions: [{ 'action_name' => 'add_label', 'action_params' => ['urgent'] }])
|
||||
expect(rule.pending_executions.armed).to be_empty
|
||||
end
|
||||
|
||||
it 'frees the episode slot so the new definition re-arms for the same episode' do
|
||||
rule.update!(actions: [{ 'action_name' => 'add_label', 'action_params' => ['urgent'] }])
|
||||
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation)
|
||||
expect(rule.pending_executions.pending.count).to eq(1)
|
||||
end
|
||||
|
||||
it 'leaves armed rows untouched on a name-only edit' do
|
||||
rule.update!(name: 'Renamed rule')
|
||||
expect(rule.pending_executions.pending.count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1230,28 +1230,4 @@ RSpec.describe Conversation do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#status_changed_at' do
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
it 'is set on create' do
|
||||
expect(conversation.status_changed_at).to be_present
|
||||
end
|
||||
|
||||
it 'is updated on every status transition' do
|
||||
original = conversation.status_changed_at
|
||||
|
||||
travel_to(1.hour.from_now) { conversation.update!(status: :resolved) }
|
||||
|
||||
expect(conversation.reload.status_changed_at).to be > original
|
||||
end
|
||||
|
||||
it 'is untouched by non-status saves' do
|
||||
original = conversation.status_changed_at
|
||||
|
||||
travel_to(1.hour.from_now) { conversation.update!(priority: :high) }
|
||||
|
||||
expect(conversation.reload.status_changed_at).to be_within(1.second).of(original)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -72,21 +72,6 @@ describe Whatsapp::SendOnWhatsappService do
|
||||
expect(message.reload.source_id).to eq('123456789')
|
||||
end
|
||||
|
||||
it 'fails a free-form message without contacting the provider when outside the 24 hour limit' do
|
||||
create(:message, message_type: :incoming, content: 'test', created_at: 25.hours.ago,
|
||||
conversation: conversation, account: conversation.account)
|
||||
message = create(:message, message_type: :outgoing, content: 'test',
|
||||
conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(Whatsapp::TemplateProcessorService).not_to receive(:new)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq(I18n.t('errors.whatsapp.message_outside_messaging_window'))
|
||||
expect(a_request(:post, 'https://waba.360dialog.io/v1/messages')).not_to have_been_made
|
||||
end
|
||||
|
||||
it 'marks message as failed when template name is blank' do
|
||||
processor = instance_double(Whatsapp::TemplateProcessorService)
|
||||
allow(Whatsapp::TemplateProcessorService).to receive(:new).and_return(processor)
|
||||
|
||||
Reference in New Issue
Block a user