feat: allow auto resolve waiting option (#11436)

This commit is contained in:
Shivam Mishra
2025-05-08 12:52:17 +05:30
committed by GitHub
parent bfddc4da24
commit c87b2109a9
10 changed files with 92 additions and 27 deletions

View File

@@ -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

View File

@@ -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."

View File

@@ -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

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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