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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
25
app/services/message_templates/template/auto_resolve.rb
Normal file
25
app/services/message_templates/template/auto_resolve.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddSettingsColumnToAccount < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :accounts, :settings, :jsonb, default: {}
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user