feat: auto resolve label option and fixes (#11541)

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Shivam Mishra
2025-06-04 14:53:24 +05:30
committed by GitHub
parent b70d2c0ebe
commit e9a132a923
13 changed files with 234 additions and 92 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, :auto_resolve_ignore_waiting) params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label)
end end
def check_signup_enabled def check_signup_enabled

View File

@@ -74,6 +74,7 @@ const updateSelected = newValue => {
<slot name="trigger" :toggle="toggle"> <slot name="trigger" :toggle="toggle">
<Button <Button
ref="triggerRef" ref="triggerRef"
type="button"
sm sm
slate slate
:variant :variant

View File

@@ -9,7 +9,14 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue';
import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue'; import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue';
const { options } = defineProps({ const {
options,
disableSearch,
placeholderIcon,
placeholder,
placeholderTrailingIcon,
searchPlaceholder,
} = defineProps({
options: { options: {
type: Array, type: Array,
required: true, required: true,
@@ -18,6 +25,22 @@ const { options } = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
placeholderIcon: {
type: String,
default: 'i-lucide-plus',
},
placeholder: {
type: String,
default: '',
},
placeholderTrailingIcon: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
}); });
const { t } = useI18n(); const { t } = useI18n();
@@ -69,15 +92,26 @@ const toggleSelected = option => {
sm sm
slate slate
faded faded
type="button"
:icon="selectedItem.icon" :icon="selectedItem.icon"
:label="selectedItem.name" :label="selectedItem.name"
@click="toggle" @click="toggle"
/> />
<Button v-else sm slate faded @click="toggle"> <Button
v-else
sm
slate
faded
type="button"
:trailing-icon="placeholderTrailingIcon"
@click="toggle"
>
<template #icon> <template #icon>
<Icon icon="i-lucide-plus" class="text-n-slate-11" /> <Icon :icon="placeholderIcon" class="text-n-slate-11" />
</template> </template>
<span class="text-n-slate-11">{{ t('COMBOBOX.PLACEHOLDER') }}</span> <span class="text-n-slate-11">{{
placeholder || t('COMBOBOX.PLACEHOLDER')
}}</span>
</Button> </Button>
</template> </template>
<DropdownBody class="top-0 min-w-56 z-50" strong> <DropdownBody class="top-0 min-w-56 z-50" strong>
@@ -87,7 +121,7 @@ const toggleSelected = option => {
v-model="searchTerm" v-model="searchTerm"
autofocus autofocus
class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full" class="p-1.5 pl-8 text-n-slate-11 bg-n-alpha-1 rounded-lg w-full"
:placeholder="t('COMBOBOX.SEARCH_PLACEHOLDER')" :placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
/> />
</div> </div>
<DropdownSection class="max-h-80 overflow-scroll"> <DropdownSection class="max-h-80 overflow-scroll">

View File

@@ -1,7 +1,8 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, watch } from 'vue';
import Input from './Input.vue'; import Input from './Input.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { DURATION_UNITS } from './constants';
const props = defineProps({ const props = defineProps({
min: { type: Number, default: 0 }, min: { type: Number, default: 0 },
@@ -11,36 +12,50 @@ const props = defineProps({
const { t } = useI18n(); const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null }); const duration = defineModel('modelValue', { type: Number, default: null });
const unit = defineModel('unit', {
type: String,
default: DURATION_UNITS.MINUTES,
validate(value) {
return Object.values(DURATION_UNITS).includes(value);
},
});
const UNIT_TYPES = { const convertToMinutes = newValue => {
MINUTES: 'minutes', if (unit.value === DURATION_UNITS.MINUTES) {
HOURS: 'hours', return Math.floor(newValue);
DAYS: 'days', }
if (unit.value === DURATION_UNITS.HOURS) {
return Math.floor(newValue) * 60;
}
return Math.floor(newValue) * 24 * 60;
}; };
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({ const transformedValue = computed({
get() { get() {
if (unit.value === UNIT_TYPES.MINUTES) return duration.value; if (unit.value === DURATION_UNITS.MINUTES) return duration.value;
if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60); if (unit.value === DURATION_UNITS.HOURS)
if (unit.value === UNIT_TYPES.DAYS) return Math.floor(duration.value / 60);
if (unit.value === DURATION_UNITS.DAYS)
return Math.floor(duration.value / 24 / 60); return Math.floor(duration.value / 24 / 60);
return 0; return 0;
}, },
set(newValue) { set(newValue) {
let minuteValue; let minuteValue = convertToMinutes(newValue);
if (unit.value === UNIT_TYPES.MINUTES) {
minuteValue = Math.floor(newValue);
} else if (unit.value === UNIT_TYPES.HOURS) {
minuteValue = Math.floor(newValue * 60);
} else if (unit.value === UNIT_TYPES.DAYS) {
minuteValue = Math.floor(newValue * 24 * 60);
}
duration.value = Math.min(Math.max(minuteValue, props.min), props.max); duration.value = Math.min(Math.max(minuteValue, props.min), props.max);
}, },
}); });
// when unit is changed set the nearest value to that unit
// so if the minute is set to 900, and the user changes the unit to "days"
// the transformed value will show 0, but the real value will still be 900
// this might create some confusion, especially when saving
// this watcher fixes it by rounding the duration basically, to the nearest unit value
watch(unit, () => {
let adjustedValue = convertToMinutes(transformedValue.value);
duration.value = Math.min(Math.max(adjustedValue, props.min), props.max);
});
</script> </script>
<template> <template>
@@ -57,10 +72,12 @@ const transformedValue = computed({
:disabled="disabled" :disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40" class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
> >
<option :value="UNIT_TYPES.MINUTES"> <option :value="DURATION_UNITS.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }} {{ t('DURATION_INPUT.MINUTES') }}
</option> </option>
<option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option> <option :value="DURATION_UNITS.HOURS">
<option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option> {{ t('DURATION_INPUT.HOURS') }}
</option>
<option :value="DURATION_UNITS.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select> </select>
</template> </template>

View File

@@ -0,0 +1,5 @@
export const DURATION_UNITS = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};

View File

@@ -45,9 +45,10 @@ export function useAccount() {
}; };
}; };
const updateAccount = async data => { const updateAccount = async (data, options) => {
await store.dispatch('accounts/update', { await store.dispatch('accounts/update', {
...data, ...data,
options,
}); });
}; };

View File

@@ -46,7 +46,31 @@
}, },
"AUTO_RESOLVE": { "AUTO_RESOLVE": {
"TITLE": "Auto-resolve conversations", "TITLE": "Auto-resolve conversations",
"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." "NOTE": "This configuration would allow you to automatically resolve the conversation after a certain period of inactivity.",
"DURATION": {
"LABEL": "Inactivity duration",
"HELP": "Time period of inactivity after which conversation is auto-resolved",
"PLACEHOLDER": "30",
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
"API": {
"SUCCESS": "Auto resolve settings updated successfully",
"ERROR": "Failed to update auto resolve settings"
}
},
"MESSAGE": {
"LABEL": "Custom auto-resolution message",
"PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
"HELP": "Message sent to the customer after conversation is auto-resolved"
},
"PREFERENCES": "Preferences",
"LABEL": {
"LABEL": "Add label after auto-resolution",
"PLACEHOLDER": "Select a label"
},
"IGNORE_WAITING": {
"LABEL": "Skip conversations waiting for agents reply"
},
"UPDATE_BUTTON": "Save Changes"
}, },
"NAME": { "NAME": {
"LABEL": "Account name", "LABEL": "Account name",
@@ -68,24 +92,6 @@
"PLACEHOLDER": "Your company's support email", "PLACEHOLDER": "Your company's support email",
"ERROR": "" "ERROR": ""
}, },
"AUTO_RESOLVE_IGNORE_WAITING": {
"LABEL": "Exclude unattended conversations",
"HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agents reply."
},
"AUTO_RESOLVE_DURATION": {
"LABEL": "Inactivity duration for resolution",
"HELP": "Duration after a conversation should auto resolve if there is no activity",
"PLACEHOLDER": "30",
"ERROR": "Auto resolve duration should be between 10 minutes and 999 days",
"API": {
"SUCCESS": "Auto resolve settings updated successfully",
"ERROR": "Failed to update auto resolve settings"
},
"UPDATE_BUTTON": "Update",
"MESSAGE_LABEL": "Custom resolution message",
"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."
},
"FEATURES": { "FEATURES": {
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.", "INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
"CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now." "CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now."

View File

@@ -1,35 +1,78 @@
<script setup> <script setup>
import { ref, watch } from 'vue'; import { h, ref, watch, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue'; import SectionLayout from './SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue'; import WithLabel from 'v3/components/Form/WithLabel.vue';
import DurationInput from 'next/input/DurationInput.vue';
import TextArea from 'next/textarea/TextArea.vue'; import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue'; import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import DurationInput from 'next/input/DurationInput.vue';
import SingleSelect from 'dashboard/components-next/filter/inputs/SingleSelect.vue';
import { DURATION_UNITS } from 'dashboard/components-next/input/constants';
const { t } = useI18n(); const { t } = useI18n();
const duration = ref(0); const duration = ref(0);
const unit = ref(DURATION_UNITS.MINUTES);
const message = ref(''); const message = ref('');
const labelToApply = ref({});
const ignoreWaiting = ref(false); const ignoreWaiting = ref(false);
const isEnabled = ref(false); const isEnabled = ref(false);
const isSubmitting = ref(false);
const { currentAccount, updateAccount } = useAccount(); const { currentAccount, updateAccount } = useAccount();
const labels = useMapGetter('labels/getLabels');
const labelOptions = computed(() =>
labels.value?.length
? labels.value.map(label => ({
id: label.title,
name: label.title,
icon: h('span', {
class: `size-[12px] ring-1 ring-n-alpha-1 dark:ring-white/20 ring-inset rounded-sm`,
style: { backgroundColor: label.color },
}),
}))
: []
);
const selectedLabelName = computed(() => {
return labelToApply.value?.name ?? null;
});
watch( watch(
currentAccount, [currentAccount, labelOptions],
() => { () => {
const { const {
auto_resolve_after, auto_resolve_after,
auto_resolve_message, auto_resolve_message,
auto_resolve_ignore_waiting, auto_resolve_ignore_waiting,
auto_resolve_label,
} = currentAccount.value?.settings || {}; } = 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; ignoreWaiting.value = auto_resolve_ignore_waiting;
// find the correct label option from the list
// the single select component expects the full label object
// in our case, the label id and name are both the same
labelToApply.value = labelOptions.value.find(
option => option.name === auto_resolve_label
);
// Set unit based on duration and its divisibility
if (duration.value) {
if (duration.value % (24 * 60) === 0) {
unit.value = DURATION_UNITS.DAYS;
} else if (duration.value % 60 === 0) {
unit.value = DURATION_UNITS.HOURS;
} else {
unit.value = DURATION_UNITS.MINUTES;
}
}
if (duration.value) { if (duration.value) {
isEnabled.value = true; isEnabled.value = true;
@@ -40,16 +83,19 @@ watch(
const updateAccountSettings = async settings => { const updateAccountSettings = async settings => {
try { try {
await updateAccount(settings); isSubmitting.value = true;
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.SUCCESS')); await updateAccount(settings, { silent: true });
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.SUCCESS'));
} catch (error) { } catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.ERROR')); useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.API.ERROR'));
} finally {
isSubmitting.value = false;
} }
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (duration.value < 10) { if (duration.value < 10) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR')); useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.DURATION.ERROR'));
return Promise.resolve(); return Promise.resolve();
} }
@@ -57,6 +103,7 @@ const handleSubmit = async () => {
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, auto_resolve_ignore_waiting: ignoreWaiting.value,
auto_resolve_label: selectedLabelName.value,
}); });
}; };
@@ -68,6 +115,7 @@ const handleDisable = async () => {
auto_resolve_after: null, auto_resolve_after: null,
auto_resolve_message: '', auto_resolve_message: '',
auto_resolve_ignore_waiting: false, auto_resolve_ignore_waiting: false,
auto_resolve_label: null,
}); });
}; };
@@ -80,6 +128,7 @@ const toggleAutoResolve = async () => {
<SectionLayout <SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')" :title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')" :description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
:hide-content="!isEnabled"
with-border with-border
> >
<template #headerActions> <template #headerActions>
@@ -90,50 +139,65 @@ const toggleAutoResolve = async () => {
<form class="grid gap-5" @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')"
> >
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]"> <div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
<!-- allow 10 mins to 999 days --> <!-- allow 10 mins to 999 days -->
<DurationInput <DurationInput
v-model="duration" v-model="duration"
v-model:unit="unit"
min="0" min="0"
max="1439856" max="1438560"
class="w-full" class="w-full"
/> />
</div> </div>
</WithLabel> </WithLabel>
<WithLabel <WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_LABEL')" :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.LABEL')"
:help-message=" :help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.HELP')"
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_HELP')
"
> >
<TextArea <TextArea
v-model="message" v-model="message"
class="w-full" class="w-full"
:placeholder=" :placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_PLACEHOLDER') t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.MESSAGE.PLACEHOLDER')
" "
/> />
</WithLabel> </WithLabel>
<WithLabel <WithLabel :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.PREFERENCES')">
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.LABEL')" <div
> class="rounded-xl border border-n-weak bg-n-solid-1 w-full text-sm text-n-slate-12 divide-y divide-n-weak"
<template #rightOfLabel> >
<Switch v-model="ignoreWaiting" /> <div class="p-3 h-12 flex items-center justify-between">
</template> <span>
<p class="text-sm ml-px text-n-slate-10 max-w-lg"> {{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.IGNORE_WAITING.LABEL') }}
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_IGNORE_WAITING.HELP') }} </span>
</p> <Switch v-model="ignoreWaiting" />
</div>
<div class="p-3 h-12 flex items-center justify-between">
<span>
{{ t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.LABEL') }}
</span>
<SingleSelect
v-model="labelToApply"
:options="labelOptions"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.LABEL.PLACEHOLDER')
"
placeholder-icon="i-lucide-chevron-down"
placeholder-trailing-icon
variant="faded"
/>
</div>
</div>
</WithLabel> </WithLabel>
<div class="flex gap-2"> <div class="flex gap-2">
<NextButton <NextButton
blue blue
type="submit" type="submit"
:label=" :is-loading="isSubmitting"
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.UPDATE_BUTTON') :label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.UPDATE_BUTTON')"
"
/> />
</div> </div>
</form> </form>

View File

@@ -1,24 +1,19 @@
<script setup> <script setup>
defineProps({ defineProps({
title: { title: { type: String, required: true },
type: String, description: { type: String, required: true },
required: true, withBorder: { type: Boolean, default: false },
}, hideContent: { type: Boolean, default: false },
description: {
type: String,
required: true,
},
withBorder: {
type: Boolean,
default: false,
},
}); });
</script> </script>
<template> <template>
<section <section
class="grid grid-cols-1 py-8 gap-8" class="grid grid-cols-1 pt-8 gap-5 [interpolate-size:allow-keywords]"
:class="{ 'border-t border-n-weak': withBorder }" :class="{
'border-t border-n-weak': withBorder,
'pb-8': !hideContent,
}"
> >
<header class="grid grid-cols-4"> <header class="grid grid-cols-4">
<div class="col-span-3"> <div class="col-span-3">
@@ -33,7 +28,10 @@ defineProps({
<slot name="headerActions" /> <slot name="headerActions" />
</div> </div>
</header> </header>
<div class="text-n-slate-12"> <div
class="transition-[height] duration-300 ease-in-out text-n-slate-12"
:class="{ 'overflow-hidden h-0': hideContent, 'h-auto': !hideContent }"
>
<slot /> <slot />
</div> </div>
</section> </section>

View File

@@ -63,8 +63,11 @@ export const actions = {
}); });
} }
}, },
update: async ({ commit }, updateObj) => { update: async ({ commit }, { options, ...updateObj }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }); if (options?.silent !== true) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
}
try { try {
const response = await AccountAPI.update('', updateObj); const response = await AccountAPI.update('', updateObj);
commit(types.default.EDIT_ACCOUNT, response.data); commit(types.default.EDIT_ACCOUNT, response.data);

View File

@@ -8,6 +8,7 @@ class Conversations::ResolutionJob < ApplicationJob
# 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
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present? ::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
conversation.add_labels(account.auto_resolve_label) if account.auto_resolve_label.present?
conversation.toggle_status conversation.toggle_status
end end
end end

View File

@@ -35,7 +35,8 @@ class Account < ApplicationRecord
{ {
'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] } 'auto_resolve_ignore_waiting': { 'type': %w[boolean null] },
'auto_resolve_label': { 'type': %w[string null] }
}, },
'required': [], 'required': [],
'additionalProperties': true 'additionalProperties': true
@@ -51,7 +52,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, :auto_resolve_ignore_waiting store_accessor :settings, :auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label
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

@@ -4,6 +4,7 @@ RSpec.describe Conversations::ResolutionJob do
subject(:job) { described_class.perform_later(account: account) } subject(:job) { described_class.perform_later(account: account) }
let!(:account) { create(:account) } let!(:account) { create(:account) }
let(:label) { create(:label, title: 'auto-resolved', account: account) }
let!(:conversation) { create(:conversation, account: account) } let!(:conversation) { create(:conversation, account: account) }
it 'enqueues the job' do it 'enqueues the job' do
@@ -47,6 +48,16 @@ RSpec.describe Conversations::ResolutionJob do
end end
end end
it 'adds a label after resolution' do
account.update(auto_resolve_label: 'auto-resolved', auto_resolve_after: 14_400)
conversation = create(:conversation, account: account, last_activity_at: 13.days.ago, waiting_since: 13.days.ago)
described_class.perform_now(account: account)
expect(conversation.reload.status).to eq('resolved')
expect(conversation.reload.label_list).to include('auto-resolved')
end
it 'resolves only a limited number of conversations in a single execution' do 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, auto_resolve_ignore_waiting: false) # 10 days in minutes account.update(auto_resolve_after: 14_400, auto_resolve_ignore_waiting: false) # 10 days in minutes