feat: Add support for minutes in auto resolve feature (#11269)

### Summary

- Converts conversation auto-resolution duration from days to minutes
for more
granular control
- Updates validation to allow values from 10 minutes (minimum) to 999
days (maximum)
- Implements smart messaging to show appropriate time units in activity
messages

###  Changes

- Created migration to convert existing durations from days to minutes
(x1440)
- Updated conversation resolver to use minutes instead of days
- Added dynamic translation key selection based on duration value
- Updated related specs and documentation
- Added support for displaying durations in days, hours, or minutes
based on value

###  Test plan

- Verify account validation accepts new minute-based ranges
- Confirm existing account settings are correctly migrated
- Test auto-resolution works properly with minute values
- Ensure proper time unit display in activity messages

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Shivam Mishra
2025-05-07 13:06:15 +05:30
committed by GitHub
parent e08436dde5
commit b533980880
34 changed files with 864 additions and 367 deletions

View File

@@ -44,8 +44,9 @@ class Api::V1::AccountsController < Api::BaseController
end end
def update def update
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration)) @account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
@account.custom_attributes.merge!(custom_attributes_params) @account.custom_attributes.merge!(custom_attributes_params)
@account.settings.merge!(settings_params)
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update' @account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save! @account.save!
end end
@@ -83,13 +84,17 @@ class Api::V1::AccountsController < Api::BaseController
end end
def account_params def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name) params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end end
def custom_attributes_params def custom_attributes_params
params.permit(:industry, :company_size, :timezone) params.permit(:industry, :company_size, :timezone)
end end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message)
end
def check_signup_enabled def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false' raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end end

View File

@@ -54,7 +54,7 @@ class Api::V2::AccountsController < Api::BaseController
end end
def account_params def account_params
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name) params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
end end
def check_signup_enabled def check_signup_enabled

View File

@@ -0,0 +1,66 @@
<script setup>
import { computed, ref } from 'vue';
import Input from './Input.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
min: { type: Number, default: 0 },
max: { type: Number, default: Infinity },
disabled: { type: Boolean, default: false },
});
const { t } = useI18n();
const duration = defineModel('modelValue', { type: Number, default: null });
const UNIT_TYPES = {
MINUTES: 'minutes',
HOURS: 'hours',
DAYS: 'days',
};
const unit = ref(UNIT_TYPES.MINUTES);
const transformedValue = computed({
get() {
if (unit.value === UNIT_TYPES.MINUTES) return duration.value;
if (unit.value === UNIT_TYPES.HOURS) return Math.floor(duration.value / 60);
if (unit.value === UNIT_TYPES.DAYS)
return Math.floor(duration.value / 24 / 60);
return 0;
},
set(newValue) {
let minuteValue;
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);
},
});
</script>
<template>
<Input
v-model="transformedValue"
type="number"
autocomplete="off"
:disabled="disabled"
:placeholder="t('DURATION_INPUT.PLACEHOLDER')"
class="flex-grow w-full disabled:"
/>
<select
v-model="unit"
:disabled="disabled"
class="mb-0 text-sm disabled:outline-n-weak disabled:opacity-40"
>
<option :value="UNIT_TYPES.MINUTES">
{{ t('DURATION_INPUT.MINUTES') }}
</option>
<option :value="UNIT_TYPES.HOURS">{{ t('DURATION_INPUT.HOURS') }}</option>
<option :value="UNIT_TYPES.DAYS">{{ t('DURATION_INPUT.DAYS') }}</option>
</select>
</template>

View File

@@ -27,10 +27,10 @@ const updateValue = () => {
> >
<span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span> <span class="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
<span <span
class="absolute top-[0.07rem] left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out" class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
:class=" :class="
modelValue modelValue
? 'translate-x-2.5 bg-white' ? 'translate-x-3 bg-white'
: 'translate-x-0 bg-white dark:bg-n-black' : 'translate-x-0 bg-white dark:bg-n-black'
" "
/> />

View File

@@ -1,6 +1,6 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useMapGetter } from './store'; import { useMapGetter, useStore } from './store';
/** /**
* Composable for account-related operations. * Composable for account-related operations.
@@ -12,6 +12,7 @@ export function useAccount() {
* @type {import('vue').ComputedRef<number>} * @type {import('vue').ComputedRef<number>}
*/ */
const route = useRoute(); const route = useRoute();
const store = useStore();
const getAccountFn = useMapGetter('accounts/getAccount'); const getAccountFn = useMapGetter('accounts/getAccount');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud'); const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const isFeatureEnabledonAccount = useMapGetter( const isFeatureEnabledonAccount = useMapGetter(
@@ -44,6 +45,12 @@ export function useAccount() {
}; };
}; };
const updateAccount = async data => {
await store.dispatch('accounts/update', {
...data,
});
};
return { return {
accountId, accountId,
route, route,
@@ -52,5 +59,6 @@ export function useAccount() {
accountScopedRoute, accountScopedRoute,
isCloudFeatureEnabled, isCloudFeatureEnabled,
isOnChatwootCloud, isOnChatwootCloud,
updateAccount,
}; };
} }

View File

@@ -43,5 +43,11 @@
"FEATURE_SPOTLIGHT": { "FEATURE_SPOTLIGHT": {
"LEARN_MORE": "Learn more", "LEARN_MORE": "Learn more",
"WATCH_VIDEO": "Watch video" "WATCH_VIDEO": "Watch video"
},
"DURATION_INPUT": {
"MINUTES": "Minutes",
"HOURS": "Hours",
"DAYS": "Days",
"PLACEHOLDER": "Enter duration"
} }
} }

View File

@@ -44,6 +44,10 @@
"TITLE": "Account ID", "TITLE": "Account ID",
"NOTE": "This ID is required if you are building an API based integration" "NOTE": "This ID is required if you are building an API based integration"
}, },
"AUTO_RESOLVE": {
"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."
},
"NAME": { "NAME": {
"LABEL": "Account name", "LABEL": "Account name",
"PLACEHOLDER": "Your account name", "PLACEHOLDER": "Your account name",
@@ -65,9 +69,18 @@
"ERROR": "" "ERROR": ""
}, },
"AUTO_RESOLVE_DURATION": { "AUTO_RESOLVE_DURATION": {
"LABEL": "Number of days after a ticket should auto resolve if there is no activity", "LABEL": "Inactivity duration for resolution",
"HELP": "Duration after a ticket 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": "Please enter a valid auto resolve duration (minimum 1 day and maximum 999 days)",
"API": {
"SUCCESS": "Auto resolve settings updated successfully",
"ERROR": "Failed to update auto resolve settings"
},
"UPDATE_BUTTON": "Update Auto-resolve",
"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.",

View File

@@ -1,25 +1,34 @@
<script> <script>
import { useVuelidate } from '@vuelidate/core'; import { useVuelidate } from '@vuelidate/core';
import { required, minValue, maxValue } from '@vuelidate/validators'; import { required } from '@vuelidate/validators';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings'; import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig'; import { useConfig } from 'dashboard/composables/useConfig';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags'; import { FEATURE_FLAGS } from '../../../../featureFlags';
import semver from 'semver';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages'; import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import NextInput from 'next/input/Input.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue'; import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import V4Button from 'dashboard/components-next/button/Button.vue';
import WootConfirmDeleteModal from 'dashboard/components/widgets/modal/ConfirmDeleteModal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue'; import NextButton from 'dashboard/components-next/button/Button.vue';
import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue';
import AutoResolve from './components/AutoResolve.vue';
import SectionLayout from './components/SectionLayout.vue';
export default { export default {
components: { components: {
BaseSettingsHeader, BaseSettingsHeader,
V4Button,
WootConfirmDeleteModal,
NextButton, NextButton,
AccountId,
BuildInfo,
AccountDelete,
AutoResolve,
SectionLayout,
WithLabel,
NextInput,
}, },
setup() { setup() {
const { updateUISettings } = useUISettings(); const { updateUISettings } = useUISettings();
@@ -37,9 +46,6 @@ export default {
domain: '', domain: '',
supportEmail: '', supportEmail: '',
features: {}, features: {},
autoResolveDuration: null,
latestChatwootVersion: null,
showDeletePopup: false,
}; };
}, },
validations: { validations: {
@@ -49,14 +55,9 @@ export default {
locale: { locale: {
required, required,
}, },
autoResolveDuration: {
minValue: minValue(1),
maxValue: maxValue(999),
},
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
globalConfig: 'globalConfig/get',
getAccount: 'accounts/getAccount', getAccount: 'accounts/getAccount',
uiFlags: 'accounts/getUIFlags', uiFlags: 'accounts/getUIFlags',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount', isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
@@ -68,16 +69,6 @@ export default {
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
); );
}, },
hasAnUpdateAvailable() {
if (!semver.valid(this.latestChatwootVersion)) {
return false;
}
return semver.lt(
this.globalConfig.appVersion,
this.latestChatwootVersion
);
},
languagesSortedByCode() { languagesSortedByCode() {
const enabledLanguages = [...this.enabledLanguages]; const enabledLanguages = [...this.enabledLanguages];
return enabledLanguages.sort((l1, l2) => return enabledLanguages.sort((l1, l2) =>
@@ -87,51 +78,19 @@ export default {
isUpdating() { isUpdating() {
return this.uiFlags.isUpdating; return this.uiFlags.isUpdating;
}, },
featureInboundEmailEnabled() { featureInboundEmailEnabled() {
return !!this.features?.inbound_emails; return !!this.features?.inbound_emails;
}, },
featureCustomReplyDomainEnabled() { featureCustomReplyDomainEnabled() {
return ( return (
this.featureInboundEmailEnabled && !!this.features.custom_reply_domain this.featureInboundEmailEnabled && !!this.features.custom_reply_domain
); );
}, },
featureCustomReplyEmailEnabled() { featureCustomReplyEmailEnabled() {
return ( return (
this.featureInboundEmailEnabled && !!this.features.custom_reply_email this.featureInboundEmailEnabled && !!this.features.custom_reply_email
); );
}, },
getAccountId() {
return this.id.toString();
},
confirmPlaceHolderText() {
return `${this.$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.PLACE_HOLDER',
{
accountName: this.name,
}
)}`;
},
isMarkedForDeletion() {
const { custom_attributes = {} } = this.currentAccount;
return !!custom_attributes.marked_for_deletion_at;
},
markedForDeletionDate() {
const { custom_attributes = {} } = this.currentAccount;
if (!custom_attributes.marked_for_deletion_at) return null;
return new Date(custom_attributes.marked_for_deletion_at);
},
markedForDeletionReason() {
const { custom_attributes = {} } = this.currentAccount;
return custom_attributes.marked_for_deletion_reason || 'manual_deletion';
},
formattedDeletionDate() {
if (!this.markedForDeletionDate) return '';
return this.markedForDeletionDate.toLocaleString();
},
currentAccount() { currentAccount() {
return this.getAccount(this.accountId) || {}; return this.getAccount(this.accountId) || {};
}, },
@@ -142,16 +101,8 @@ export default {
methods: { methods: {
async initializeAccount() { async initializeAccount() {
try { try {
const { const { name, locale, id, domain, support_email, features } =
name, this.getAccount(this.accountId);
locale,
id,
domain,
support_email,
features,
auto_resolve_duration,
latest_chatwoot_version: latestChatwootVersion,
} = this.getAccount(this.accountId);
this.$root.$i18n.locale = locale; this.$root.$i18n.locale = locale;
this.name = name; this.name = name;
@@ -160,8 +111,6 @@ export default {
this.domain = domain; this.domain = domain;
this.supportEmail = support_email; this.supportEmail = support_email;
this.features = features; this.features = features;
this.autoResolveDuration = auto_resolve_duration;
this.latestChatwootVersion = latestChatwootVersion;
} catch (error) { } catch (error) {
// Ignore error // Ignore error
} }
@@ -179,7 +128,6 @@ export default {
name: this.name, name: this.name,
domain: this.domain, domain: this.domain,
support_email: this.supportEmail, support_email: this.supportEmail,
auto_resolve_duration: this.autoResolveDuration,
}); });
this.$root.$i18n.locale = this.locale; this.$root.$i18n.locale = this.locale;
this.getAccount(this.id).locale = this.locale; this.getAccount(this.id).locale = this.locale;
@@ -196,252 +144,101 @@ export default {
rtl_view: isRTLSupported, rtl_view: isRTLSupported,
}); });
}, },
// Delete Function
openDeletePopup() {
this.showDeletePopup = true;
},
closeDeletePopup() {
this.showDeletePopup = false;
},
async markAccountForDeletion() {
this.closeDeletePopup();
try {
// Use the enterprise API to toggle deletion with delete action
await this.$store.dispatch('accounts/toggleDeletion', {
action_type: 'delete',
});
// Refresh account data
await this.$store.dispatch('accounts/get');
useAlert(this.$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SUCCESS'));
} catch (error) {
// Handle error message
this.handleDeletionError(error);
}
},
handleDeletionError(error) {
const errorKey = error.response?.data?.error_key;
if (errorKey) {
useAlert(
this.$t(`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.${errorKey}`)
);
return;
}
const message = error.response?.data?.message;
if (message) {
useAlert(message);
return;
}
useAlert(this.$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.FAILURE'));
},
async clearDeletionMark() {
try {
// Use the enterprise API to toggle deletion with undelete action
await this.$store.dispatch('accounts/toggleDeletion', {
action_type: 'undelete',
});
// Refresh account data
await this.$store.dispatch('accounts/get');
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
},
}, },
}; };
</script> </script>
<template> <template>
<div class="flex flex-col w-full"> <div class="flex flex-col max-w-2xl mx-auto w-full">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')"> <BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')" />
<template #actions>
<V4Button blue :loading="isUpdating" @click="updateAccount">
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
</V4Button>
</template>
</BaseSettingsHeader>
<div class="flex-grow flex-shrink min-w-0 mt-3 overflow-auto"> <div class="flex-grow flex-shrink min-w-0 mt-3 overflow-auto">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount"> <SectionLayout
<div :title="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE')"
class="flex flex-row border-b border-slate-25 dark:border-slate-800" :description="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE')"
>
<form
v-if="!uiFlags.isFetchingItem"
class="grid gap-4"
@submit.prevent="updateAccount"
> >
<div <WithLabel
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0" :has-error="v$.name.$error"
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
> >
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200"> <NextInput
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }} v-model="name"
</h4> type="text"
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p> class="w-full"
</div> :placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]"> @blur="v$.name.$touch"
<label :class="{ error: v$.name.$error }"> />
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }} </WithLabel>
<input <WithLabel
v-model="name" :has-error="v$.locale.$error"
type="text" :label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')" :error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
@blur="v$.name.$touch" >
/> <select v-model="locale" class="!mb-0 text-sm">
<span v-if="v$.name.$error" class="message"> <option
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }} v-for="lang in languagesSortedByCode"
</span> :key="lang.iso_639_1_code"
</label> :value="lang.iso_639_1_code"
<label :class="{ error: v$.locale.$error }"> >
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }} {{ lang.name }}
<select v-model="locale"> </option>
<option </select>
v-for="lang in languagesSortedByCode" </WithLabel>
:key="lang.iso_639_1_code" <WithLabel
:value="lang.iso_639_1_code" v-if="featureCustomReplyDomainEnabled"
> :label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
{{ lang.name }} >
</option> <NextInput
</select> v-model="domain"
<span v-if="v$.locale.$error" class="message"> type="text"
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }} class="w-full"
</span> :placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
</label> />
<label v-if="featureInboundEmailEnabled"> <template #help>
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ {{
featureInboundEmailEnabled &&
$t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED')
}}
{{
featureCustomReplyDomainEnabled &&
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED') $t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}} }}
</label> </template>
<label v-if="featureCustomReplyDomainEnabled"> </WithLabel>
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }} <WithLabel
<input v-if="featureCustomReplyEmailEnabled"
v-model="domain" :label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
type="text" >
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')" <NextInput
/> v-model="supportEmail"
</label> type="text"
<label v-if="featureCustomReplyEmailEnabled"> class="w-full"
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }} :placeholder="
<input $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
v-model="supportEmail" "
type="text" />
:placeholder=" </WithLabel>
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER') <div>
" <NextButton blue :is-loading="isUpdating" type="submit">
/> {{ $t('GENERAL_SETTINGS.SUBMIT') }}
</label> </NextButton>
<label
v-if="showAutoResolutionConfig"
:class="{ error: v$.autoResolveDuration.$error }"
>
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
<input
v-model="autoResolveDuration"
type="number"
:placeholder="
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
"
@blur="v$.autoResolveDuration.$touch"
/>
<span v-if="v$.autoResolveDuration.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
</span>
</label>
</div> </div>
</div> </form>
</form> </SectionLayout>
<woot-loading-state v-if="uiFlags.isFetchingItem" /> <woot-loading-state v-if="uiFlags.isFetchingItem" />
</div> </div>
<AutoResolve v-if="showAutoResolutionConfig" />
<div class="flex flex-row"> <AccountId />
<div class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0">
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE') }}
</h4>
<p>
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE') }}
</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<woot-code :script="getAccountId" />
</div>
</div>
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud"> <div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<div <AccountDelete />
class="flex flex-row pt-4 mt-2 border-t border-slate-25 dark:border-slate-800 text-black-900 dark:text-slate-300"
>
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
>
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
{{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE') }}
</h4>
<p>
{{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE') }}
</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<div v-if="isMarkedForDeletion">
<div
class="p-4 flex-grow-0 flex-shrink-0 flex-[50%] bg-red-50 dark:bg-red-900 rounded"
>
<p class="mb-4">
{{
$t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_${markedForDeletionReason === 'manual_deletion' ? 'MANUAL' : 'INACTIVITY'}`,
{
deletionDate: formattedDeletionDate,
}
)
}}
</p>
<NextButton
:label="
$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.CLEAR_BUTTON'
)
"
color="ruby"
:is-loading="uiFlags.isUpdating"
@click="clearDeletionMark"
/>
</div>
</div>
<div v-if="!isMarkedForDeletion">
<NextButton
:label="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.BUTTON_TEXT')"
color="ruby"
@click="openDeletePopup()"
/>
</div>
</div>
</div>
<WootConfirmDeleteModal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.TITLE')"
:message="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.MESSAGE')"
:confirm-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.BUTTON_TEXT')
"
:reject-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.DISMISS')
"
:confirm-value="name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="markAccountForDeletion"
@on-close="closeDeletePopup"
/>
</div>
<div class="p-4 text-sm text-center">
<div>{{ `v${globalConfig.appVersion}` }}</div>
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: latestChatwootVersion,
})
}}
</div>
<div class="build-id">
<div>{{ `Build ${globalConfig.gitSha}` }}</div>
</div>
</div> </div>
<BuildInfo />
</div> </div>
</template> </template>

View File

@@ -0,0 +1,149 @@
<script setup>
import { computed } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useAlert } from 'dashboard/composables';
import WootConfirmDeleteModal from 'dashboard/components/widgets/modal/ConfirmDeleteModal.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const store = useStore();
const uiFlags = useMapGetter('accounts/getUIFlags');
const { currentAccount } = useAccount();
const [showDeletePopup, toggleDeletePopup] = useToggle();
const confirmPlaceHolderText = computed(() => {
return `${t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.PLACE_HOLDER', {
accountName: currentAccount.value.name,
})}`;
});
const isMarkedForDeletion = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
return !!custom_attributes.marked_for_deletion_at;
});
const markedForDeletionDate = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
if (!custom_attributes.marked_for_deletion_at) return null;
return new Date(custom_attributes.marked_for_deletion_at);
});
const markedForDeletionReason = computed(() => {
const { custom_attributes = {} } = currentAccount.value;
return custom_attributes.marked_for_deletion_reason || 'manual_deletion';
});
const formattedDeletionDate = computed(() => {
if (!markedForDeletionDate.value) return '';
return markedForDeletionDate.value.toLocaleString();
});
const markedForDeletionMessage = computed(() => {
const params = { deletionDate: formattedDeletionDate.value };
if (markedForDeletionReason.value === 'manual_deletion') {
return t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_MANUAL`,
params
);
}
return t(
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_INACTIVITY`,
params
);
});
function handleDeletionError(error) {
const message = error.response?.data?.message;
if (message) {
useAlert(message);
return;
}
useAlert(t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.FAILURE'));
}
async function markAccountForDeletion() {
toggleDeletePopup(false);
try {
// Use the enterprise API to toggle deletion with delete action
await store.dispatch('accounts/toggleDeletion', {
action_type: 'delete',
});
// Refresh account data
await store.dispatch('accounts/get');
useAlert(t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SUCCESS'));
} catch (error) {
// Handle error message
handleDeletionError(error);
}
}
async function clearDeletionMark() {
try {
// Use the enterprise API to toggle deletion with undelete action
await store.dispatch('accounts/toggleDeletion', {
action_type: 'undelete',
});
// Refresh account data
await store.dispatch('accounts/get');
useAlert(t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.UPDATE.ERROR'));
}
}
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE')"
:description="t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE')"
with-border
>
<div v-if="isMarkedForDeletion">
<div
class="p-4 flex-grow-0 flex-shrink-0 flex-[50%] bg-red-50 dark:bg-red-900 rounded"
>
<p class="mb-4">
{{ markedForDeletionMessage }}
</p>
<NextButton
:label="
$t(
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.CLEAR_BUTTON'
)
"
color="ruby"
:is-loading="uiFlags.isUpdating"
@click="clearDeletionMark"
/>
</div>
</div>
<div v-if="!isMarkedForDeletion">
<NextButton
:label="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.BUTTON_TEXT')"
color="ruby"
@click="toggleDeletePopup(true)"
/>
</div>
</SectionLayout>
<WootConfirmDeleteModal
v-if="showDeletePopup"
v-model:show="showDeletePopup"
:title="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.TITLE')"
:message="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.MESSAGE')"
:confirm-text="
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.BUTTON_TEXT')
"
:reject-text="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.DISMISS')"
:confirm-value="currentAccount.name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="markAccountForDeletion"
@on-close="toggleDeletePopup(false)"
/>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useI18n } from 'vue-i18n';
import SectionLayout from './SectionLayout.vue';
const { t } = useI18n();
const { currentAccount } = useAccount();
const getAccountId = computed(() => currentAccount.value.id.toString());
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE')"
with-border
>
<woot-code :script="getAccountId" />
</SectionLayout>
</template>

View File

@@ -0,0 +1,121 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import DurationInput from 'next/input/DurationInput.vue';
import TextArea from 'next/textarea/TextArea.vue';
import Switch from 'next/switch/Switch.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const duration = ref(0);
const message = ref('');
const isEnabled = ref(false);
const { currentAccount, updateAccount } = useAccount();
watch(
currentAccount,
() => {
const { auto_resolve_after, auto_resolve_message } =
currentAccount.value?.settings || {};
duration.value = auto_resolve_after;
message.value = auto_resolve_message;
if (duration.value) {
isEnabled.value = true;
}
},
{ deep: true, immediate: true }
);
const updateAccountSettings = async settings => {
try {
await updateAccount(settings);
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.API.ERROR'));
}
};
const handleSubmit = async () => {
return updateAccountSettings({
auto_resolve_after: duration.value,
auto_resolve_message: message.value,
});
};
const handleDisable = async () => {
duration.value = null;
message.value = '';
return updateAccountSettings({
auto_resolve_after: null,
auto_resolve_message: '',
});
};
const toggleAutoResolve = async () => {
if (!isEnabled.value) handleDisable();
};
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE.NOTE')"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggleAutoResolve" />
</div>
</template>
<form class="grid gap-4" @submit.prevent="handleSubmit">
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL')"
:help-message="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.HELP')"
>
<div class="gap-2 w-full grid grid-cols-[3fr_1fr]">
<!-- allow 10 mins to 999 days -->
<DurationInput
v-model="duration"
:disabled="!isEnabled"
min="10"
max="1439856"
class="w-full"
/>
</div>
</WithLabel>
<WithLabel
:label="t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_LABEL')"
:help-message="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_HELP')
"
>
<TextArea
v-model="message"
class="w-full"
:disabled="!isEnabled"
:placeholder="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.MESSAGE_PLACEHOLDER')
"
/>
</WithLabel>
<div class="flex gap-2">
<NextButton
blue
type="submit"
:label="
t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.UPDATE_BUTTON')
"
/>
</div>
</form>
</SectionLayout>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import { computed } from 'vue';
import { useAccount } from 'dashboard/composables/useAccount';
import { useMapGetter } from 'dashboard/composables/store';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useI18n } from 'vue-i18n';
import semver from 'semver';
const { t } = useI18n();
const { currentAccount } = useAccount();
const latestChatwootVersion = computed(() => {
return currentAccount.value.latest_chatwoot_version;
});
const globalConfig = useMapGetter('globalConfig/get');
const hasAnUpdateAvailable = computed(() => {
if (!semver.valid(latestChatwootVersion.value)) {
return false;
}
return semver.lt(globalConfig.value.appVersion, latestChatwootVersion.value);
});
const gitSha = computed(() => {
return globalConfig.value.gitSha.substring(0, 7);
});
const copyGitSha = () => {
copyTextToClipboard(globalConfig.value.gitSha);
};
</script>
<template>
<div class="p-4 text-sm text-center">
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
{{
t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
latestChatwootVersion: latestChatwootVersion,
})
}}
</div>
<div class="divide-x divide-n-slate-9">
<span class="px-2">{{ `v${globalConfig.appVersion}` }}</span>
<span
v-tooltip="t('COMPONENTS.CODE.BUTTON_TEXT')"
class="px-2 build-id cursor-pointer"
@click="copyGitSha"
>
{{ `Build ${gitSha}` }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
withBorder: {
type: Boolean,
default: false,
},
});
</script>
<template>
<section
class="grid grid-cols-1 py-8 gap-10"
:class="{ 'border-t border-n-weak': withBorder }"
>
<header class="grid grid-cols-4">
<div class="col-span-3">
<h4 class="text-lg font-medium text-n-slate-12">
<slot name="title">{{ title }}</slot>
</h4>
<p class="text-n-slate-11">
<slot name="description">{{ description }}</slot>
</p>
</div>
<div class="col-span-1">
<slot name="headerActions" />
</div>
</header>
<div class="text-n-slate-12">
<slot />
</div>
</section>
</template>

View File

@@ -66,7 +66,8 @@ export const actions = {
update: async ({ commit }, updateObj) => { update: async ({ commit }, updateObj) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }); commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try { try {
await AccountAPI.update('', updateObj); const response = await AccountAPI.update('', updateObj);
commit(types.default.EDIT_ACCOUNT, response.data);
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }); commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
} catch (error) { } catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }); commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });

View File

@@ -39,10 +39,13 @@ describe('#actions', () => {
describe('#update', () => { describe('#update', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue(); axios.patch.mockResolvedValue({
data: { id: 1, name: 'John' },
});
await actions.update({ commit, getters }, accountData); await actions.update({ commit, getters }, accountData);
expect(commit.mock.calls).toEqual([ expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }], [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
[types.default.EDIT_ACCOUNT, { id: 1, name: 'John' }],
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }], [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
]); ]);
}); });

View File

@@ -1,28 +1,12 @@
<script> <script setup>
export default { defineProps({
props: { label: { type: String, default: '' },
label: { name: { type: String, required: true },
type: String, icon: { type: String, default: '' },
default: '', hasError: { type: Boolean, default: false },
}, helpMessage: { type: String, default: '' },
name: { errorMessage: { type: String, default: '' },
type: String, });
required: true,
},
icon: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
},
};
</script> </script>
<template> <template>
@@ -30,8 +14,8 @@ export default {
<label <label
v-if="label" v-if="label"
:for="name" :for="name"
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white" class="flex justify-between text-sm font-medium leading-6 text-n-slate-12"
:class="{ 'text-red-500': hasError }" :class="{ 'text-n-ruby-12': hasError }"
> >
<slot name="label"> <slot name="label">
{{ label }} {{ label }}
@@ -44,16 +28,24 @@ export default {
v-if="icon" v-if="icon"
size="16" size="16"
:icon="icon" :icon="icon"
class="absolute left-2 transform text-slate-400 dark:text-slate-600 w-5 h-5" class="absolute left-2 transform text-n-slate-9 w-5 h-5"
/> />
<slot /> <slot />
</div> </div>
<span <div
v-if="errorMessage && hasError" v-if="errorMessage && hasError"
class="text-xs text-n-ruby-9 dark:text-n-ruby-9 leading-2" class="text-xs mt-2 ml-px text-n-ruby-9 leading-tight"
> >
{{ errorMessage }} {{ errorMessage }}
</span> </div>
<div
v-else-if="helpMessage || $slots.help"
class="text-xs mt-2 ml-px text-n-slate-10 leading-tight"
>
<slot name="help">
{{ helpMessage }}
</slot>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -2,7 +2,7 @@ class Account::ConversationsResolutionSchedulerJob < ApplicationJob
queue_as :scheduled_jobs queue_as :scheduled_jobs
def perform def perform
Account.where.not(auto_resolve_duration: nil).all.find_each(batch_size: 100) do |account| Account.with_auto_resolve.find_each(batch_size: 100) do |account|
Conversations::ResolutionJob.perform_later(account: account) Conversations::ResolutionJob.perform_later(account: account)
end end
end end

View File

@@ -3,7 +3,12 @@ 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_duration).limit(Limits::BULK_ACTIONS_LIMIT) resolvable_conversations = account.conversations.resolvable(account.auto_resolve_after).limit(Limits::BULK_ACTIONS_LIMIT)
resolvable_conversations.each(&:toggle_status) resolvable_conversations.each do |conversation|
# send message from bot that conversation has been resolved
# do this is account.auto_resolve_message is set
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
conversation.toggle_status
end
end end
end end

View File

@@ -11,6 +11,7 @@
# limits :jsonb # limits :jsonb
# locale :integer default("en") # locale :integer default("en")
# name :string not null # name :string not null
# settings :jsonb
# status :integer default("active") # status :integer default("active")
# support_email :string(100) # support_email :string(100)
# created_at :datetime not null # created_at :datetime not null
@@ -28,13 +29,28 @@ class Account < ApplicationRecord
include Featurable include Featurable
include CacheKeys include CacheKeys
SETTINGS_PARAMS_SCHEMA = {
'type': 'object',
'properties':
{
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
'auto_resolve_message': { 'type': %w[string null] }
},
'required': [],
'additionalProperties': false
}.to_json.freeze
DEFAULT_QUERY_SETTING = { DEFAULT_QUERY_SETTING = {
flag_query_mode: :bit_operator, flag_query_mode: :bit_operator,
check_for_column: false check_for_column: false
}.freeze }.freeze
validates :auto_resolve_duration, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 999, allow_nil: true }
validates :domain, length: { maximum: 100 } validates :domain, length: { maximum: 100 }
validates_with JsonSchemaValidator,
schema: SETTINGS_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.settings }
store_accessor :settings, :auto_resolve_after, :auto_resolve_message
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
@@ -83,6 +99,8 @@ class Account < ApplicationRecord
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
enum status: { active: 0, suspended: 1 } enum status: { active: 0, suspended: 1 }
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
before_validation :validate_limit_keys before_validation :validate_limit_keys
after_create_commit :notify_creation after_create_commit :notify_creation
after_destroy :remove_account_sequences after_destroy :remove_account_sequences

View File

@@ -56,13 +56,24 @@ module ActivityMessageHandler
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
end end
def auto_resolve_message_key(minutes)
if minutes >= 1440 && (minutes % 1440).zero?
{ key: 'auto_resolved_days', count: minutes / 1440 }
elsif minutes >= 60 && (minutes % 60).zero?
{ key: 'auto_resolved_hours', count: minutes / 60 }
else
{ key: 'auto_resolved_minutes', count: minutes }
end
end
def user_status_change_activity_content(user_name) def user_status_change_activity_content(user_name)
if user_name if user_name
I18n.t("conversations.activity.status.#{status}", user_name: user_name) I18n.t("conversations.activity.status.#{status}", user_name: user_name)
elsif Current.contact.present? && resolved? elsif Current.contact.present? && resolved?
I18n.t('conversations.activity.status.contact_resolved', contact_name: Current.contact.name.capitalize) I18n.t('conversations.activity.status.contact_resolved', contact_name: Current.contact.name.capitalize)
elsif resolved? elsif resolved?
I18n.t('conversations.activity.status.auto_resolved', duration: auto_resolve_duration) message_data = auto_resolve_message_key(auto_resolve_after || 0)
I18n.t("conversations.activity.status.#{message_data[:key]}", count: message_data[:count])
end end
end end

View File

@@ -56,6 +56,8 @@ class JsonSchemaValidator < ActiveModel::Validator
def format_and_append_error(error, record) def format_and_append_error(error, record)
return handle_required(error, record) if error['type'] == 'required' return handle_required(error, record) if error['type'] == 'required'
return handle_minimum(error, record) if error['type'] == 'minimum'
return handle_maximum(error, record) if error['type'] == 'maximum'
type = error['type'] == 'object' ? 'hash' : error['type'] type = error['type'] == 'object' ? 'hash' : error['type']
@@ -74,6 +76,16 @@ class JsonSchemaValidator < ActiveModel::Validator
record.errors.add(data, "must be of type #{expected_type}") record.errors.add(data, "must be of type #{expected_type}")
end end
def handle_minimum(error, record)
data = get_name_from_data_pointer(error)
record.errors.add(data, "must be greater than or equal to #{error['schema']['minimum']}")
end
def handle_maximum(error, record)
data = get_name_from_data_pointer(error)
record.errors.add(data, "must be less than or equal to #{error['schema']['maximum']}")
end
def get_name_from_data_pointer(error) def get_name_from_data_pointer(error)
data = error['data_pointer'] data = error['data_pointer']

View File

@@ -76,10 +76,10 @@ 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_duration| scope :resolvable, lambda { |auto_resolve_after|
return none if auto_resolve_duration.to_i.zero? return none if auto_resolve_after.to_i.zero?
open.where('last_activity_at < ? ', Time.now.utc - auto_resolve_duration.days) open.where('last_activity_at < ? AND waiting_since IS NULL', Time.now.utc - auto_resolve_after.minutes)
} }
scope :last_user_message_at, lambda { scope :last_user_message_at, lambda {
@@ -112,7 +112,7 @@ class Conversation < ApplicationRecord
after_create_commit :notify_conversation_creation after_create_commit :notify_conversation_creation
after_create_commit :load_attributes_created_by_db_triggers after_create_commit :load_attributes_created_by_db_triggers
delegate :auto_resolve_duration, to: :account delegate :auto_resolve_after, to: :account
def can_reply? def can_reply?
Conversations::MessageWindowService.new(self).can_reply? Conversations::MessageWindowService.new(self).can_reply?

View File

@@ -0,0 +1,25 @@
class MessageTemplates::Template::AutoResolve
pattr_initialize [:conversation!]
def perform
return if conversation.account.auto_resolve_message.blank?
ActiveRecord::Base.transaction do
conversation.messages.create!(auto_resolve_message_params)
end
end
private
delegate :contact, :account, to: :conversation
delegate :inbox, to: :message
def auto_resolve_message_params
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: :template,
content: account.auto_resolve_message
}
end
end

View File

@@ -1,4 +1,4 @@
json.auto_resolve_duration resource.auto_resolve_duration json.settings resource.settings
json.created_at resource.created_at json.created_at resource.created_at
if resource.custom_attributes.present? if resource.custom_attributes.present?
json.custom_attributes do json.custom_attributes do

View File

@@ -159,7 +159,9 @@ en:
open: 'Conversation was reopened by %{user_name}' open: 'Conversation was reopened by %{user_name}'
pending: 'Conversation was marked as pending by %{user_name}' pending: 'Conversation was marked as pending by %{user_name}'
snoozed: 'Conversation was snoozed by %{user_name}' snoozed: 'Conversation was snoozed by %{user_name}'
auto_resolved: 'Conversation was marked resolved by system due to %{duration} days of inactivity' auto_resolved_days: 'Conversation was marked resolved by system due to %{count} days of inactivity'
auto_resolved_hours: 'Conversation was marked resolved by system due to %{count} hours of inactivity'
auto_resolved_minutes: 'Conversation was marked resolved by system due to %{count} minutes of inactivity'
system_auto_open: System reopened the conversation due to a new incoming message. system_auto_open: System reopened the conversation due to a new incoming message.
priority: priority:
added: '%{user_name} set the priority to %{new_priority}' added: '%{user_name} set the priority to %{new_priority}'

View File

@@ -0,0 +1,5 @@
class AddSettingsColumnToAccount < ActiveRecord::Migration[7.0]
def change
add_column :accounts, :settings, :jsonb, default: {}
end
end

View File

@@ -0,0 +1,8 @@
class UpdateAutoResolveToMminutes < ActiveRecord::Migration[7.0]
def change
Account.where.not(auto_resolve_duration: nil).each do |account|
account.auto_resolve_after = account.auto_resolve_duration * 60 * 24
account.save!
end
end
end

View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2025_04_16_182131) do ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do
# These extensions should be enabled to support this database # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@@ -58,6 +58,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_04_16_182131) do
t.jsonb "custom_attributes", default: {} t.jsonb "custom_attributes", default: {}
t.integer "status", default: 0 t.integer "status", default: 0
t.jsonb "internal_attributes", default: {}, null: false t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
t.index ["status"], name: "index_accounts_on_status" t.index ["status"], name: "index_accounts_on_status"
end end

View File

@@ -119,7 +119,7 @@ RSpec.describe 'Accounts API', type: :request do
context 'when it is an authenticated user' do context 'when it is an authenticated user' do
it 'shows an account' do it 'shows an account' do
account.update(auto_resolve_duration: 30) account.update(name: 'new name')
get "/api/v1/accounts/#{account.id}", get "/api/v1/accounts/#{account.id}",
headers: admin.create_new_auth_token, headers: admin.create_new_auth_token,
@@ -130,7 +130,6 @@ RSpec.describe 'Accounts API', type: :request do
expect(response.body).to include(account.locale) expect(response.body).to include(account.locale)
expect(response.body).to include(account.domain) expect(response.body).to include(account.domain)
expect(response.body).to include(account.support_email) expect(response.body).to include(account.support_email)
expect(response.body).to include(account.auto_resolve_duration.to_s)
expect(response.body).to include(account.locale) expect(response.body).to include(account.locale)
end end
end end
@@ -189,7 +188,8 @@ RSpec.describe 'Accounts API', type: :request do
locale: 'en', locale: 'en',
domain: 'example.com', domain: 'example.com',
support_email: 'care@example.com', support_email: 'care@example.com',
auto_resolve_duration: 40, auto_resolve_after: 40,
auto_resolve_message: 'Auto resolved',
timezone: 'Asia/Kolkata', timezone: 'Asia/Kolkata',
industry: 'Technology', industry: 'Technology',
company_size: '1-10' company_size: '1-10'
@@ -206,7 +206,10 @@ RSpec.describe 'Accounts API', type: :request do
expect(account.reload.locale).to eq(params[:locale]) expect(account.reload.locale).to eq(params[:locale])
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])
expect(account.reload.auto_resolve_duration).to eq(params[:auto_resolve_duration])
%w[auto_resolve_after auto_resolve_message].each do |attribute|
expect(account.reload.settings[attribute]).to eq(params[attribute.to_sym])
end
%w[timezone industry company_size].each do |attribute| %w[timezone industry company_size].each do |attribute|
expect(account.reload.custom_attributes[attribute]).to eq(params[attribute.to_sym]) expect(account.reload.custom_attributes[attribute]).to eq(params[attribute.to_sym])

View File

@@ -11,7 +11,7 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob do
end end
it 'enqueues Conversations::ResolutionJob' do it 'enqueues Conversations::ResolutionJob' do
account.update(auto_resolve_duration: 10) account.update(auto_resolve_after: 10 * 60 * 24)
expect(Conversations::ResolutionJob).to receive(:perform_later).with(account: account).once expect(Conversations::ResolutionJob).to receive(:perform_later).with(account: account).once
described_class.perform_now described_class.perform_now
end end

View File

@@ -18,16 +18,17 @@ RSpec.describe Conversations::ResolutionJob do
end end
it 'resolves the issue if time of inactivity is more than the auto resolve duration' do it 'resolves the issue if time of inactivity is more than the auto resolve duration' do
account.update(auto_resolve_duration: 10) account.update(auto_resolve_after: 14_400) # 10 days in minutes
conversation.update(last_activity_at: 13.days.ago) conversation.update(last_activity_at: 13.days.ago, waiting_since: nil)
described_class.perform_now(account: account) described_class.perform_now(account: account)
expect(conversation.reload.status).to eq('resolved') expect(conversation.reload.status).to eq('resolved')
end end
it 'resolved only a limited number of conversations in a single execution' do it 'resolved 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_duration: 10) account.update(auto_resolve_after: 14_400) # 10 days in minutes
create_list(:conversation, 3, account: account, last_activity_at: 13.days.ago) conversations = 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

View File

@@ -3,9 +3,6 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Account do RSpec.describe Account do
it { is_expected.to validate_numericality_of(:auto_resolve_duration).is_greater_than_or_equal_to(1) }
it { is_expected.to validate_numericality_of(:auto_resolve_duration).is_less_than_or_equal_to(999) }
it { is_expected.to have_many(:users).through(:account_users) } it { is_expected.to have_many(:users).through(:account_users) }
it { is_expected.to have_many(:account_users) } it { is_expected.to have_many(:account_users) }
it { is_expected.to have_many(:inboxes).dependent(:destroy_async) } it { is_expected.to have_many(:inboxes).dependent(:destroy_async) }
@@ -134,4 +131,86 @@ RSpec.describe Account do
expect(account.locale_english_name).to eq('portuguese') expect(account.locale_english_name).to eq('portuguese')
end end
end end
describe 'settings' do
let(:account) { create(:account) }
context 'when auto_resolve_after' do
it 'validates minimum value' do
account.settings = { auto_resolve_after: 4 }
expect(account).to be_invalid
expect(account.errors.messages).to eq({ auto_resolve_after: ['must be greater than or equal to 10'] })
end
it 'validates maximum value' do
account.settings = { auto_resolve_after: 1_439_857 }
expect(account).to be_invalid
expect(account.errors.messages).to eq({ auto_resolve_after: ['must be less than or equal to 1439856'] })
end
it 'allows valid values' do
account.settings = { auto_resolve_after: 15 }
expect(account).to be_valid
account.settings = { auto_resolve_after: 1_439_856 }
expect(account).to be_valid
end
it 'allows null values' do
account.settings = { auto_resolve_after: nil }
expect(account).to be_valid
end
end
context 'when auto_resolve_message' do
it 'allows string values' do
account.settings = { auto_resolve_message: 'This conversation has been resolved automatically.' }
expect(account).to be_valid
end
it 'allows empty string' do
account.settings = { auto_resolve_message: '' }
expect(account).to be_valid
end
it 'allows nil values' do
account.settings = { auto_resolve_message: nil }
expect(account).to be_valid
end
end
context 'when using store_accessor' do
it 'correctly gets and sets auto_resolve_after' do
account.auto_resolve_after = 30
expect(account.auto_resolve_after).to eq(30)
expect(account.settings['auto_resolve_after']).to eq(30)
end
it 'correctly gets and sets auto_resolve_message' do
message = 'This conversation was automatically resolved'
account.auto_resolve_message = message
expect(account.auto_resolve_message).to eq(message)
expect(account.settings['auto_resolve_message']).to eq(message)
end
it 'handles nil values correctly' do
account.auto_resolve_after = nil
account.auto_resolve_message = nil
expect(account.auto_resolve_after).to be_nil
expect(account.auto_resolve_message).to be_nil
end
end
context 'when using with_auto_resolve scope' do
it 'finds accounts with auto_resolve_after set' do
account.update(auto_resolve_after: 40 * 24 * 60)
expect(described_class.with_auto_resolve).to include(account)
end
it 'does not find accounts without auto_resolve_after' do
account.update(auto_resolve_after: nil)
expect(described_class.with_auto_resolve).not_to include(account)
end
end
end
end end

View File

@@ -5,9 +5,10 @@ RSpec.describe JsonSchemaValidator, type: :validator do
'type' => 'object', 'type' => 'object',
'properties' => { 'properties' => {
'name' => { 'type' => 'string' }, 'name' => { 'type' => 'string' },
'age' => { 'type' => 'integer' }, 'age' => { 'type' => 'integer', 'minimum' => 18, 'maximum' => 100 },
'is_active' => { 'type' => 'boolean' }, 'is_active' => { 'type' => 'boolean' },
'tags' => { 'type' => 'array' }, 'tags' => { 'type' => 'array' },
'score' => { 'type' => 'number', 'minimum' => 0, 'maximum' => 10 },
'address' => { 'address' => {
'type' => 'object', 'type' => 'object',
'properties' => { 'properties' => {
@@ -109,4 +110,44 @@ RSpec.describe JsonSchemaValidator, type: :validator do
:is_active => ['must be of type boolean'], :tags => ['must be of type array'] }) :is_active => ['must be of type boolean'], :tags => ['must be of type array'] })
end end
end end
context 'with value below minimum' do
let(:invalid_data) do
{
'name' => 'John Doe',
'age' => 15,
'score' => -1,
'is_active' => true
}
end
it 'fails validation' do
model = TestModelForJSONValidation.new(invalid_data)
expect(model.valid?).to be false
expect(model.errors.messages).to eq({
:age => ['must be greater than or equal to 18'],
:score => ['must be greater than or equal to 0']
})
end
end
context 'with value above maximum' do
let(:invalid_data) do
{
'name' => 'John Doe',
'age' => 120,
'score' => 11,
'is_active' => true
}
end
it 'fails validation' do
model = TestModelForJSONValidation.new(invalid_data)
expect(model.valid?).to be false
expect(model.errors.messages).to eq({
:age => ['must be less than or equal to 100'],
:score => ['must be less than or equal to 10']
})
end
end
end end

View File

@@ -213,11 +213,18 @@ RSpec.describe Conversation do
end end
it 'adds a message for system auto resolution if marked resolved by system' do it 'adds a message for system auto resolution if marked resolved by system' do
account.update(auto_resolve_duration: 40) account.update(auto_resolve_after: 40 * 24 * 60)
conversation2 = create(:conversation, status: 'open', account: account, assignee: old_assignee) conversation2 = create(:conversation, status: 'open', account: account, assignee: old_assignee)
Current.user = nil Current.user = nil
system_resolved_message = "Conversation was marked resolved by system due to #{account.auto_resolve_duration} days of inactivity" message_data = if account.auto_resolve_after >= 1440 && account.auto_resolve_after % 1440 == 0
{ key: 'auto_resolved_days', count: account.auto_resolve_after / 1440 }
elsif account.auto_resolve_after >= 60 && account.auto_resolve_after % 60 == 0
{ key: 'auto_resolved_hours', count: account.auto_resolve_after / 60 }
else
{ key: 'auto_resolved_minutes', count: account.auto_resolve_after }
end
system_resolved_message = "Conversation was marked resolved by system due to #{message_data[:count]} days of inactivity"
expect { conversation2.update(status: :resolved) } expect { conversation2.update(status: :resolved) }
.to have_enqueued_job(Conversations::ActivityMessageJob) .to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation2, { account_id: conversation2.account_id, inbox_id: conversation2.inbox_id, message_type: :activity, .with(conversation2, { account_id: conversation2.account_id, inbox_id: conversation2.inbox_id, message_type: :activity,