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
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.settings.merge!(settings_params)
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
@account.save!
end
@@ -83,13 +84,17 @@ class Api::V1::AccountsController < Api::BaseController
end
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
def custom_attributes_params
params.permit(:industry, :company_size, :timezone)
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message)
end
def check_signup_enabled
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
end

View File

@@ -54,7 +54,7 @@ class Api::V2::AccountsController < Api::BaseController
end
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
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="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="
modelValue
? 'translate-x-2.5 bg-white'
? 'translate-x-3 bg-white'
: 'translate-x-0 bg-white dark:bg-n-black'
"
/>

View File

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

View File

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

View File

@@ -44,6 +44,10 @@
"TITLE": "Account ID",
"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": {
"LABEL": "Account name",
"PLACEHOLDER": "Your account name",
@@ -65,9 +69,18 @@
"ERROR": ""
},
"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",
"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": {
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",

View File

@@ -1,25 +1,34 @@
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, minValue, maxValue } from '@vuelidate/validators';
import { required } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import semver from 'semver';
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 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 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 {
components: {
BaseSettingsHeader,
V4Button,
WootConfirmDeleteModal,
NextButton,
AccountId,
BuildInfo,
AccountDelete,
AutoResolve,
SectionLayout,
WithLabel,
NextInput,
},
setup() {
const { updateUISettings } = useUISettings();
@@ -37,9 +46,6 @@ export default {
domain: '',
supportEmail: '',
features: {},
autoResolveDuration: null,
latestChatwootVersion: null,
showDeletePopup: false,
};
},
validations: {
@@ -49,14 +55,9 @@ export default {
locale: {
required,
},
autoResolveDuration: {
minValue: minValue(1),
maxValue: maxValue(999),
},
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
getAccount: 'accounts/getAccount',
uiFlags: 'accounts/getUIFlags',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
@@ -68,16 +69,6 @@ export default {
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
);
},
hasAnUpdateAvailable() {
if (!semver.valid(this.latestChatwootVersion)) {
return false;
}
return semver.lt(
this.globalConfig.appVersion,
this.latestChatwootVersion
);
},
languagesSortedByCode() {
const enabledLanguages = [...this.enabledLanguages];
return enabledLanguages.sort((l1, l2) =>
@@ -87,51 +78,19 @@ export default {
isUpdating() {
return this.uiFlags.isUpdating;
},
featureInboundEmailEnabled() {
return !!this.features?.inbound_emails;
},
featureCustomReplyDomainEnabled() {
return (
this.featureInboundEmailEnabled && !!this.features.custom_reply_domain
);
},
featureCustomReplyEmailEnabled() {
return (
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() {
return this.getAccount(this.accountId) || {};
},
@@ -142,16 +101,8 @@ export default {
methods: {
async initializeAccount() {
try {
const {
name,
locale,
id,
domain,
support_email,
features,
auto_resolve_duration,
latest_chatwoot_version: latestChatwootVersion,
} = this.getAccount(this.accountId);
const { name, locale, id, domain, support_email, features } =
this.getAccount(this.accountId);
this.$root.$i18n.locale = locale;
this.name = name;
@@ -160,8 +111,6 @@ export default {
this.domain = domain;
this.supportEmail = support_email;
this.features = features;
this.autoResolveDuration = auto_resolve_duration;
this.latestChatwootVersion = latestChatwootVersion;
} catch (error) {
// Ignore error
}
@@ -179,7 +128,6 @@ export default {
name: this.name,
domain: this.domain,
support_email: this.supportEmail,
auto_resolve_duration: this.autoResolveDuration,
});
this.$root.$i18n.locale = this.locale;
this.getAccount(this.id).locale = this.locale;
@@ -196,252 +144,101 @@ export default {
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>
<template>
<div class="flex flex-col w-full">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')">
<template #actions>
<V4Button blue :loading="isUpdating" @click="updateAccount">
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
</V4Button>
</template>
</BaseSettingsHeader>
<div class="flex flex-col max-w-2xl mx-auto w-full">
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')" />
<div class="flex-grow flex-shrink min-w-0 mt-3 overflow-auto">
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
<div
class="flex flex-row border-b border-slate-25 dark:border-slate-800"
<SectionLayout
:title="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE')"
:description="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE')"
>
<form
v-if="!uiFlags.isFetchingItem"
class="grid gap-4"
@submit.prevent="updateAccount"
>
<div
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
<WithLabel
: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">
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
</h4>
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
</div>
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
<label :class="{ error: v$.name.$error }">
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
<input
v-model="name"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
<span v-if="v$.name.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
</span>
</label>
<label :class="{ error: v$.locale.$error }">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
<select v-model="locale">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
<span v-if="v$.locale.$error" class="message">
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
</span>
</label>
<label v-if="featureInboundEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
</label>
<label v-if="featureCustomReplyDomainEnabled">
<NextInput
v-model="name"
type="text"
class="w-full"
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
@blur="v$.name.$touch"
/>
</WithLabel>
<WithLabel
:has-error="v$.locale.$error"
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
:error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
>
<select v-model="locale" class="!mb-0 text-sm">
<option
v-for="lang in languagesSortedByCode"
:key="lang.iso_639_1_code"
:value="lang.iso_639_1_code"
>
{{ lang.name }}
</option>
</select>
</WithLabel>
<WithLabel
v-if="featureCustomReplyDomainEnabled"
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
>
<NextInput
v-model="domain"
type="text"
class="w-full"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
<template #help>
{{
featureInboundEmailEnabled &&
$t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED')
}}
{{
featureCustomReplyDomainEnabled &&
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
}}
</label>
<label v-if="featureCustomReplyDomainEnabled">
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
<input
v-model="domain"
type="text"
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
/>
</label>
<label v-if="featureCustomReplyEmailEnabled">
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
<input
v-model="supportEmail"
type="text"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</label>
<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>
</template>
</WithLabel>
<WithLabel
v-if="featureCustomReplyEmailEnabled"
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
>
<NextInput
v-model="supportEmail"
type="text"
class="w-full"
:placeholder="
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
"
/>
</WithLabel>
<div>
<NextButton blue :is-loading="isUpdating" type="submit">
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
</NextButton>
</div>
</div>
</form>
</form>
</SectionLayout>
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<div class="flex flex-row">
<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>
<AutoResolve v-if="showAutoResolutionConfig" />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<div
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>
<AccountDelete />
</div>
<BuildInfo />
</div>
</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) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
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 });
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });

View File

@@ -39,10 +39,13 @@ describe('#actions', () => {
describe('#update', () => {
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);
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
[types.default.EDIT_ACCOUNT, { id: 1, name: 'John' }],
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
]);
});

View File

@@ -1,28 +1,12 @@
<script>
export default {
props: {
label: {
type: String,
default: '',
},
name: {
type: String,
required: true,
},
icon: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
errorMessage: {
type: String,
default: '',
},
},
};
<script setup>
defineProps({
label: { type: String, default: '' },
name: { type: String, required: true },
icon: { type: String, default: '' },
hasError: { type: Boolean, default: false },
helpMessage: { type: String, default: '' },
errorMessage: { type: String, default: '' },
});
</script>
<template>
@@ -30,8 +14,8 @@ export default {
<label
v-if="label"
:for="name"
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
:class="{ 'text-red-500': hasError }"
class="flex justify-between text-sm font-medium leading-6 text-n-slate-12"
:class="{ 'text-n-ruby-12': hasError }"
>
<slot name="label">
{{ label }}
@@ -44,16 +28,24 @@ export default {
v-if="icon"
size="16"
: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 />
</div>
<span
<div
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 }}
</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>
</template>

View File

@@ -2,7 +2,7 @@ class Account::ConversationsResolutionSchedulerJob < ApplicationJob
queue_as :scheduled_jobs
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)
end
end

View File

@@ -3,7 +3,12 @@ class Conversations::ResolutionJob < ApplicationJob
def perform(account:)
# 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.each(&:toggle_status)
resolvable_conversations = account.conversations.resolvable(account.auto_resolve_after).limit(Limits::BULK_ACTIONS_LIMIT)
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

View File

@@ -11,6 +11,7 @@
# limits :jsonb
# locale :integer default("en")
# name :string not null
# settings :jsonb
# status :integer default("active")
# support_email :string(100)
# created_at :datetime not null
@@ -28,13 +29,28 @@ class Account < ApplicationRecord
include Featurable
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 = {
flag_query_mode: :bit_operator,
check_for_column: false
}.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_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 :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 status: { active: 0, suspended: 1 }
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
before_validation :validate_limit_keys
after_create_commit :notify_creation
after_destroy :remove_account_sequences

View File

@@ -56,13 +56,24 @@ module ActivityMessageHandler
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
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)
if user_name
I18n.t("conversations.activity.status.#{status}", user_name: user_name)
elsif Current.contact.present? && resolved?
I18n.t('conversations.activity.status.contact_resolved', contact_name: Current.contact.name.capitalize)
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

View File

@@ -56,6 +56,8 @@ class JsonSchemaValidator < ActiveModel::Validator
def format_and_append_error(error, record)
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']
@@ -74,6 +76,16 @@ class JsonSchemaValidator < ActiveModel::Validator
record.errors.add(data, "must be of type #{expected_type}")
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)
data = error['data_pointer']

View File

@@ -76,10 +76,10 @@ class Conversation < ApplicationRecord
scope :assigned, -> { where.not(assignee_id: nil) }
scope :assigned_to, ->(agent) { where(assignee_id: agent.id) }
scope :unattended, -> { where(first_reply_created_at: nil).or(where.not(waiting_since: nil)) }
scope :resolvable, lambda { |auto_resolve_duration|
return none if auto_resolve_duration.to_i.zero?
scope :resolvable, lambda { |auto_resolve_after|
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 {
@@ -112,7 +112,7 @@ class Conversation < ApplicationRecord
after_create_commit :notify_conversation_creation
after_create_commit :load_attributes_created_by_db_triggers
delegate :auto_resolve_duration, to: :account
delegate :auto_resolve_after, to: :account
def 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
if resource.custom_attributes.present?
json.custom_attributes do

View File

@@ -159,7 +159,9 @@ en:
open: 'Conversation was reopened by %{user_name}'
pending: 'Conversation was marked as pending 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.
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.
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
enable_extension "pg_stat_statements"
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.integer "status", default: 0
t.jsonb "internal_attributes", default: {}, null: false
t.jsonb "settings", default: {}
t.index ["status"], name: "index_accounts_on_status"
end

View File

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

View File

@@ -11,7 +11,7 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob do
end
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
described_class.perform_now
end

View File

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

View File

@@ -3,9 +3,6 @@
require 'rails_helper'
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(:account_users) }
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')
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

View File

@@ -5,9 +5,10 @@ RSpec.describe JsonSchemaValidator, type: :validator do
'type' => 'object',
'properties' => {
'name' => { 'type' => 'string' },
'age' => { 'type' => 'integer' },
'age' => { 'type' => 'integer', 'minimum' => 18, 'maximum' => 100 },
'is_active' => { 'type' => 'boolean' },
'tags' => { 'type' => 'array' },
'score' => { 'type' => 'number', 'minimum' => 0, 'maximum' => 10 },
'address' => {
'type' => 'object',
'properties' => {
@@ -109,4 +110,44 @@ RSpec.describe JsonSchemaValidator, type: :validator do
:is_active => ['must be of type boolean'], :tags => ['must be of type array'] })
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

View File

@@ -213,11 +213,18 @@ RSpec.describe Conversation do
end
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)
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) }
.to have_enqueued_job(Conversations::ActivityMessageJob)
.with(conversation2, { account_id: conversation2.account_id, inbox_id: conversation2.inbox_id, message_type: :activity,