feat: allow auto resolve waiting option (#11436)
This commit is contained in:
@@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.permit(:auto_resolve_after, :auto_resolve_message)
|
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting)
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
},
|
},
|
||||||
"AUTO_RESOLVE": {
|
"AUTO_RESOLVE": {
|
||||||
"TITLE": "Auto-resolve conversations",
|
"TITLE": "Auto-resolve conversations",
|
||||||
"NOTE": "This configuration would allow you to automatically end the conversation after a certain period. Set the duration and customize the message to the user below."
|
"NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period. Set the duration and customize the message to the user below."
|
||||||
},
|
},
|
||||||
"NAME": {
|
"NAME": {
|
||||||
"LABEL": "Account name",
|
"LABEL": "Account name",
|
||||||
@@ -68,16 +68,20 @@
|
|||||||
"PLACEHOLDER": "Your company's support email",
|
"PLACEHOLDER": "Your company's support email",
|
||||||
"ERROR": ""
|
"ERROR": ""
|
||||||
},
|
},
|
||||||
|
"AUTO_RESOLVE_IGNORE_WAITING": {
|
||||||
|
"LABEL": "Exclude unattended conversations",
|
||||||
|
"HELP": "If toggled, the system will not resolve conversations that have been waiting for an agent reply."
|
||||||
|
},
|
||||||
"AUTO_RESOLVE_DURATION": {
|
"AUTO_RESOLVE_DURATION": {
|
||||||
"LABEL": "Inactivity duration for resolution",
|
"LABEL": "Inactivity duration for resolution",
|
||||||
"HELP": "Duration after a conversation should auto resolve if there is no activity",
|
"HELP": "Duration after a conversation should auto resolve if there is no activity",
|
||||||
"PLACEHOLDER": "30",
|
"PLACEHOLDER": "30",
|
||||||
"ERROR": "Please enter a valid auto resolve duration (minimum 1 day and maximum 999 days)",
|
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
|
||||||
"API": {
|
"API": {
|
||||||
"SUCCESS": "Auto resolve settings updated successfully",
|
"SUCCESS": "Auto resolve settings updated successfully",
|
||||||
"ERROR": "Failed to update auto resolve settings"
|
"ERROR": "Failed to update auto resolve settings"
|
||||||
},
|
},
|
||||||
"UPDATE_BUTTON": "Update Auto-resolve",
|
"UPDATE_BUTTON": "Update",
|
||||||
"MESSAGE_LABEL": "Custom resolution message",
|
"MESSAGE_LABEL": "Custom resolution message",
|
||||||
"MESSAGE_PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
|
"MESSAGE_PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
|
||||||
"MESSAGE_HELP": "This message is sent to the customer when a conversation is automatically resolved by the system due to inactivity."
|
"MESSAGE_HELP": "This message is sent to the customer when a conversation is automatically resolved by the system due to inactivity."
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const duration = ref(0);
|
const duration = ref(0);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
|
const ignoreWaiting = ref(false);
|
||||||
const isEnabled = ref(false);
|
const isEnabled = ref(false);
|
||||||
|
|
||||||
const { currentAccount, updateAccount } = useAccount();
|
const { currentAccount, updateAccount } = useAccount();
|
||||||
@@ -20,11 +21,15 @@ const { currentAccount, updateAccount } = useAccount();
|
|||||||
watch(
|
watch(
|
||||||
currentAccount,
|
currentAccount,
|
||||||
() => {
|
() => {
|
||||||
const { auto_resolve_after, auto_resolve_message } =
|
const {
|
||||||
currentAccount.value?.settings || {};
|
auto_resolve_after,
|
||||||
|
auto_resolve_message,
|
||||||
|
auto_resolve_ignore_waiting,
|
||||||
|
} = currentAccount.value?.settings || {};
|
||||||
|
|
||||||
duration.value = auto_resolve_after;
|
duration.value = auto_resolve_after;
|
||||||
message.value = auto_resolve_message;
|
message.value = auto_resolve_message;
|
||||||
|
ignoreWaiting.value = auto_resolve_ignore_waiting;
|
||||||
|
|
||||||
if (duration.value) {
|
if (duration.value) {
|
||||||
isEnabled.value = true;
|
isEnabled.value = true;
|
||||||
@@ -43,9 +48,15 @@ const updateAccountSettings = async settings => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
if (duration.value < 10) {
|
||||||
|
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR'));
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
return updateAccountSettings({
|
return updateAccountSettings({
|
||||||
auto_resolve_after: duration.value,
|
auto_resolve_after: duration.value,
|
||||||
auto_resolve_message: message.value,
|
auto_resolve_message: message.value,
|
||||||
|
auto_resolve_ignore_waiting: ignoreWaiting.value,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,6 +67,7 @@ const handleDisable = async () => {
|
|||||||
return updateAccountSettings({
|
return updateAccountSettings({
|
||||||
auto_resolve_after: null,
|
auto_resolve_after: null,
|
||||||
auto_resolve_message: '',
|
auto_resolve_message: '',
|
||||||
|
auto_resolve_ignore_waiting: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,7 +88,7 @@ const toggleAutoResolve = async () => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<form class="grid gap-4" @submit.prevent="handleSubmit">
|
<form class="grid gap-5" @submit.prevent="handleSubmit">
|
||||||
<WithLabel
|
<WithLabel
|
||||||
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL')"
|
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL')"
|
||||||
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.HELP')"
|
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.HELP')"
|
||||||
@@ -85,7 +97,7 @@ const toggleAutoResolve = async () => {
|
|||||||
<!-- allow 10 mins to 999 days -->
|
<!-- allow 10 mins to 999 days -->
|
||||||
<DurationInput
|
<DurationInput
|
||||||
v-model="duration"
|
v-model="duration"
|
||||||
min="10"
|
min="0"
|
||||||
max="1439856"
|
max="1439856"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
@@ -105,6 +117,16 @@ const toggleAutoResolve = async () => {
|
|||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
|
<WithLabel
|
||||||
|
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.LABEL')"
|
||||||
|
>
|
||||||
|
<template #rightOfLabel>
|
||||||
|
<Switch v-model="ignoreWaiting" />
|
||||||
|
</template>
|
||||||
|
<p class="text-sm ml-px text-n-slate-10 max-w-lg">
|
||||||
|
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.HELP') }}
|
||||||
|
</p>
|
||||||
|
</WithLabel>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<NextButton
|
<NextButton
|
||||||
blue
|
blue
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ defineProps({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section
|
||||||
class="grid grid-cols-1 py-8 gap-10"
|
class="grid grid-cols-1 py-8 gap-8"
|
||||||
:class="{ 'border-t border-n-weak': withBorder }"
|
:class="{ 'border-t border-n-weak': withBorder }"
|
||||||
>
|
>
|
||||||
<header class="grid grid-cols-4">
|
<header class="grid grid-cols-4">
|
||||||
@@ -25,7 +25,7 @@ defineProps({
|
|||||||
<h4 class="text-lg font-medium text-n-slate-12">
|
<h4 class="text-lg font-medium text-n-slate-12">
|
||||||
<slot name="title">{{ title }}</slot>
|
<slot name="title">{{ title }}</slot>
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-n-slate-11">
|
<p class="text-n-slate-11 text-sm mt-2">
|
||||||
<slot name="description">{{ description }}</slot>
|
<slot name="description">{{ description }}</slot>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ defineProps({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage && hasError"
|
v-if="errorMessage && hasError"
|
||||||
class="text-xs mt-2 ml-px text-n-ruby-9 leading-tight"
|
class="text-sm mt-1.5 ml-px text-n-ruby-9 leading-tight"
|
||||||
>
|
>
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="helpMessage || $slots.help"
|
v-else-if="helpMessage || $slots.help"
|
||||||
class="text-xs mt-2 ml-px text-n-slate-10 leading-tight"
|
class="text-sm mt-1.5 ml-px text-n-slate-10 leading-tight"
|
||||||
>
|
>
|
||||||
<slot name="help">
|
<slot name="help">
|
||||||
{{ helpMessage }}
|
{{ helpMessage }}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class Conversations::ResolutionJob < ApplicationJob
|
|||||||
|
|
||||||
def perform(account:)
|
def perform(account:)
|
||||||
# limiting the number of conversations to be resolved to avoid any performance issues
|
# limiting the number of conversations to be resolved to avoid any performance issues
|
||||||
resolvable_conversations = account.conversations.resolvable(account.auto_resolve_after).limit(Limits::BULK_ACTIONS_LIMIT)
|
resolvable_conversations = conversation_scope(account).limit(Limits::BULK_ACTIONS_LIMIT)
|
||||||
resolvable_conversations.each do |conversation|
|
resolvable_conversations.each do |conversation|
|
||||||
# send message from bot that conversation has been resolved
|
# send message from bot that conversation has been resolved
|
||||||
# do this is account.auto_resolve_message is set
|
# do this is account.auto_resolve_message is set
|
||||||
@@ -11,4 +11,14 @@ class Conversations::ResolutionJob < ApplicationJob
|
|||||||
conversation.toggle_status
|
conversation.toggle_status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def conversation_scope(account)
|
||||||
|
if account.auto_resolve_ignore_waiting
|
||||||
|
account.conversations.resolvable_not_waiting(account.auto_resolve_after)
|
||||||
|
else
|
||||||
|
account.conversations.resolvable_all(account.auto_resolve_after)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ class Account < ApplicationRecord
|
|||||||
'properties':
|
'properties':
|
||||||
{
|
{
|
||||||
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
|
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
|
||||||
'auto_resolve_message': { 'type': %w[string null] }
|
'auto_resolve_message': { 'type': %w[string null] },
|
||||||
|
'auto_resolve_ignore_waiting': { 'type': %w[boolean null] }
|
||||||
},
|
},
|
||||||
'required': [],
|
'required': [],
|
||||||
'additionalProperties': false
|
'additionalProperties': true
|
||||||
}.to_json.freeze
|
}.to_json.freeze
|
||||||
|
|
||||||
DEFAULT_QUERY_SETTING = {
|
DEFAULT_QUERY_SETTING = {
|
||||||
@@ -50,7 +51,7 @@ class Account < ApplicationRecord
|
|||||||
schema: SETTINGS_PARAMS_SCHEMA,
|
schema: SETTINGS_PARAMS_SCHEMA,
|
||||||
attribute_resolver: ->(record) { record.settings }
|
attribute_resolver: ->(record) { record.settings }
|
||||||
|
|
||||||
store_accessor :settings, :auto_resolve_after, :auto_resolve_message
|
store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting
|
||||||
|
|
||||||
has_many :account_users, dependent: :destroy_async
|
has_many :account_users, dependent: :destroy_async
|
||||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||||
|
|||||||
@@ -76,11 +76,16 @@ class Conversation < ApplicationRecord
|
|||||||
scope :assigned, -> { where.not(assignee_id: nil) }
|
scope :assigned, -> { where.not(assignee_id: nil) }
|
||||||
scope :assigned_to, ->(agent) { where(assignee_id: agent.id) }
|
scope :assigned_to, ->(agent) { where(assignee_id: agent.id) }
|
||||||
scope :unattended, -> { where(first_reply_created_at: nil).or(where.not(waiting_since: nil)) }
|
scope :unattended, -> { where(first_reply_created_at: nil).or(where.not(waiting_since: nil)) }
|
||||||
scope :resolvable, lambda { |auto_resolve_after|
|
scope :resolvable_not_waiting, lambda { |auto_resolve_after|
|
||||||
return none if auto_resolve_after.to_i.zero?
|
return none if auto_resolve_after.to_i.zero?
|
||||||
|
|
||||||
open.where('last_activity_at < ? AND waiting_since IS NULL', Time.now.utc - auto_resolve_after.minutes)
|
open.where('last_activity_at < ? AND waiting_since IS NULL', Time.now.utc - auto_resolve_after.minutes)
|
||||||
}
|
}
|
||||||
|
scope :resolvable_all, lambda { |auto_resolve_after|
|
||||||
|
return none if auto_resolve_after.to_i.zero?
|
||||||
|
|
||||||
|
open.where('last_activity_at < ?', Time.now.utc - auto_resolve_after.minutes)
|
||||||
|
}
|
||||||
|
|
||||||
scope :last_user_message_at, lambda {
|
scope :last_user_message_at, lambda {
|
||||||
joins(
|
joins(
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
support_email: 'care@example.com',
|
support_email: 'care@example.com',
|
||||||
auto_resolve_after: 40,
|
auto_resolve_after: 40,
|
||||||
auto_resolve_message: 'Auto resolved',
|
auto_resolve_message: 'Auto resolved',
|
||||||
|
auto_resolve_ignore_waiting: false,
|
||||||
timezone: 'Asia/Kolkata',
|
timezone: 'Asia/Kolkata',
|
||||||
industry: 'Technology',
|
industry: 'Technology',
|
||||||
company_size: '1-10'
|
company_size: '1-10'
|
||||||
@@ -207,7 +208,7 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
expect(account.reload.domain).to eq(params[:domain])
|
expect(account.reload.domain).to eq(params[:domain])
|
||||||
expect(account.reload.support_email).to eq(params[:support_email])
|
expect(account.reload.support_email).to eq(params[:support_email])
|
||||||
|
|
||||||
%w[auto_resolve_after auto_resolve_message].each do |attribute|
|
%w[auto_resolve_after auto_resolve_message auto_resolve_ignore_waiting].each do |attribute|
|
||||||
expect(account.reload.settings[attribute]).to eq(params[attribute.to_sym])
|
expect(account.reload.settings[attribute]).to eq(params[attribute.to_sym])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,40 @@ RSpec.describe Conversations::ResolutionJob do
|
|||||||
expect(conversation.reload.status).to eq('open')
|
expect(conversation.reload.status).to eq('open')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves the issue if time of inactivity is more than the auto resolve duration' do
|
context 'when auto_resolve_ignore_waiting is true' do
|
||||||
account.update(auto_resolve_after: 14_400) # 10 days in minutes
|
it 'resolves non-waiting conversations if time of inactivity is more than auto resolve duration' do
|
||||||
conversation.update(last_activity_at: 13.days.ago, waiting_since: nil)
|
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: true) # 10 days in minutes
|
||||||
described_class.perform_now(account: account)
|
conversation.update(last_activity_at: 13.days.ago, waiting_since: nil)
|
||||||
expect(conversation.reload.status).to eq('resolved')
|
described_class.perform_now(account: account)
|
||||||
|
expect(conversation.reload.status).to eq('resolved')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not resolve waiting conversations even if time of inactivity is more than auto resolve duration' do
|
||||||
|
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: true) # 10 days in minutes
|
||||||
|
conversation.update(last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
|
||||||
|
described_class.perform_now(account: account)
|
||||||
|
expect(conversation.reload.status).to eq('open')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolved only a limited number of conversations in a single execution' do
|
context 'when auto_resolve_ignore_waiting is false' do
|
||||||
|
it 'resolves all conversations if time of inactivity is more than auto resolve duration' do
|
||||||
|
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
|
||||||
|
# Create one waiting conversation and one non-waiting conversation
|
||||||
|
waiting_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
|
||||||
|
non_waiting_conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: nil)
|
||||||
|
|
||||||
|
described_class.perform_now(account: account)
|
||||||
|
|
||||||
|
expect(waiting_conversation.reload.status).to eq('resolved')
|
||||||
|
expect(non_waiting_conversation.reload.status).to eq('resolved')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'resolves only a limited number of conversations in a single execution' do
|
||||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
||||||
account.update(auto_resolve_after: 14_400) # 10 days in minutes
|
account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes
|
||||||
conversations = create_list(:conversation, 3, account: account, last_activity_at: 13.days.ago)
|
create_list(:conversation, 3, account: account, last_activity_at: 13.days.ago)
|
||||||
conversations.each { |conversation| conversation.update(waiting_since: nil) }
|
|
||||||
described_class.perform_now(account: account)
|
described_class.perform_now(account: account)
|
||||||
expect(account.conversations.resolved.count).to eq(Limits::BULK_ACTIONS_LIMIT)
|
expect(account.conversations.resolved.count).to eq(Limits::BULK_ACTIONS_LIMIT)
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user