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
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
|
@account.assign_attributes(account_params.slice(:name, :locale, :domain, :support_email))
|
||||||
@account.custom_attributes.merge!(custom_attributes_params)
|
@account.custom_attributes.merge!(custom_attributes_params)
|
||||||
|
@account.settings.merge!(settings_params)
|
||||||
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
@account.custom_attributes['onboarding_step'] = 'invite_team' if @account.custom_attributes['onboarding_step'] == 'account_update'
|
||||||
@account.save!
|
@account.save!
|
||||||
end
|
end
|
||||||
@@ -83,13 +84,17 @@ class Api::V1::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def account_params
|
def account_params
|
||||||
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
|
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def custom_attributes_params
|
def custom_attributes_params
|
||||||
params.permit(:industry, :company_size, :timezone)
|
params.permit(:industry, :company_size, :timezone)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def settings_params
|
||||||
|
params.permit(:auto_resolve_after, :auto_resolve_message)
|
||||||
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
raise ActionController::RoutingError, 'Not Found' if GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false') == 'false'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class Api::V2::AccountsController < Api::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def account_params
|
def account_params
|
||||||
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
|
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :user_full_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_signup_enabled
|
def check_signup_enabled
|
||||||
|
|||||||
@@ -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="sr-only">{{ t('SWITCH.TOGGLE') }}</span>
|
||||||
<span
|
<span
|
||||||
class="absolute top-[0.07rem] left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
|
class="absolute top-0.5 left-0.5 h-3 w-3 transform rounded-full shadow-sm transition-transform duration-200 ease-in-out"
|
||||||
:class="
|
:class="
|
||||||
modelValue
|
modelValue
|
||||||
? 'translate-x-2.5 bg-white'
|
? 'translate-x-3 bg-white'
|
||||||
: 'translate-x-0 bg-white dark:bg-n-black'
|
: 'translate-x-0 bg-white dark:bg-n-black'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useMapGetter } from './store';
|
import { useMapGetter, useStore } from './store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for account-related operations.
|
* Composable for account-related operations.
|
||||||
@@ -12,6 +12,7 @@ export function useAccount() {
|
|||||||
* @type {import('vue').ComputedRef<number>}
|
* @type {import('vue').ComputedRef<number>}
|
||||||
*/
|
*/
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const store = useStore();
|
||||||
const getAccountFn = useMapGetter('accounts/getAccount');
|
const getAccountFn = useMapGetter('accounts/getAccount');
|
||||||
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
|
||||||
const isFeatureEnabledonAccount = useMapGetter(
|
const isFeatureEnabledonAccount = useMapGetter(
|
||||||
@@ -44,6 +45,12 @@ export function useAccount() {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateAccount = async data => {
|
||||||
|
await store.dispatch('accounts/update', {
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
route,
|
route,
|
||||||
@@ -52,5 +59,6 @@ export function useAccount() {
|
|||||||
accountScopedRoute,
|
accountScopedRoute,
|
||||||
isCloudFeatureEnabled,
|
isCloudFeatureEnabled,
|
||||||
isOnChatwootCloud,
|
isOnChatwootCloud,
|
||||||
|
updateAccount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,5 +43,11 @@
|
|||||||
"FEATURE_SPOTLIGHT": {
|
"FEATURE_SPOTLIGHT": {
|
||||||
"LEARN_MORE": "Learn more",
|
"LEARN_MORE": "Learn more",
|
||||||
"WATCH_VIDEO": "Watch video"
|
"WATCH_VIDEO": "Watch video"
|
||||||
|
},
|
||||||
|
"DURATION_INPUT": {
|
||||||
|
"MINUTES": "Minutes",
|
||||||
|
"HOURS": "Hours",
|
||||||
|
"DAYS": "Days",
|
||||||
|
"PLACEHOLDER": "Enter duration"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
"TITLE": "Account ID",
|
"TITLE": "Account ID",
|
||||||
"NOTE": "This ID is required if you are building an API based integration"
|
"NOTE": "This ID is required if you are building an API based integration"
|
||||||
},
|
},
|
||||||
|
"AUTO_RESOLVE": {
|
||||||
|
"TITLE": "Auto-resolve conversations",
|
||||||
|
"NOTE": "This configuration would allow you to automatically end the conversation after a certain period. Set the duration and customize the message to the user below."
|
||||||
|
},
|
||||||
"NAME": {
|
"NAME": {
|
||||||
"LABEL": "Account name",
|
"LABEL": "Account name",
|
||||||
"PLACEHOLDER": "Your account name",
|
"PLACEHOLDER": "Your account name",
|
||||||
@@ -65,9 +69,18 @@
|
|||||||
"ERROR": ""
|
"ERROR": ""
|
||||||
},
|
},
|
||||||
"AUTO_RESOLVE_DURATION": {
|
"AUTO_RESOLVE_DURATION": {
|
||||||
"LABEL": "Number of days after a ticket should auto resolve if there is no activity",
|
"LABEL": "Inactivity duration for resolution",
|
||||||
|
"HELP": "Duration after a ticket should auto resolve if there is no activity",
|
||||||
"PLACEHOLDER": "30",
|
"PLACEHOLDER": "30",
|
||||||
"ERROR": "Please enter a valid auto resolve duration (minimum 1 day and maximum 999 days)"
|
"ERROR": "Please enter a valid auto resolve duration (minimum 1 day and maximum 999 days)",
|
||||||
|
"API": {
|
||||||
|
"SUCCESS": "Auto resolve settings updated successfully",
|
||||||
|
"ERROR": "Failed to update auto resolve settings"
|
||||||
|
},
|
||||||
|
"UPDATE_BUTTON": "Update Auto-resolve",
|
||||||
|
"MESSAGE_LABEL": "Custom resolution message",
|
||||||
|
"MESSAGE_PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity",
|
||||||
|
"MESSAGE_HELP": "This message is sent to the customer when a conversation is automatically resolved by the system due to inactivity."
|
||||||
},
|
},
|
||||||
"FEATURES": {
|
"FEATURES": {
|
||||||
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
|
"INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.",
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
<script>
|
<script>
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, minValue, maxValue } from '@vuelidate/validators';
|
import { required } from '@vuelidate/validators';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import { useAccount } from 'dashboard/composables/useAccount';
|
||||||
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../../featureFlags';
|
||||||
import semver from 'semver';
|
|
||||||
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||||
|
import WithLabel from 'v3/components/Form/WithLabel.vue';
|
||||||
|
import NextInput from 'next/input/Input.vue';
|
||||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||||
import V4Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
import WootConfirmDeleteModal from 'dashboard/components/widgets/modal/ConfirmDeleteModal.vue';
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import AccountId from './components/AccountId.vue';
|
||||||
|
import BuildInfo from './components/BuildInfo.vue';
|
||||||
|
import AccountDelete from './components/AccountDelete.vue';
|
||||||
|
import AutoResolve from './components/AutoResolve.vue';
|
||||||
|
import SectionLayout from './components/SectionLayout.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
BaseSettingsHeader,
|
BaseSettingsHeader,
|
||||||
V4Button,
|
|
||||||
WootConfirmDeleteModal,
|
|
||||||
NextButton,
|
NextButton,
|
||||||
|
AccountId,
|
||||||
|
BuildInfo,
|
||||||
|
AccountDelete,
|
||||||
|
AutoResolve,
|
||||||
|
SectionLayout,
|
||||||
|
WithLabel,
|
||||||
|
NextInput,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { updateUISettings } = useUISettings();
|
const { updateUISettings } = useUISettings();
|
||||||
@@ -37,9 +46,6 @@ export default {
|
|||||||
domain: '',
|
domain: '',
|
||||||
supportEmail: '',
|
supportEmail: '',
|
||||||
features: {},
|
features: {},
|
||||||
autoResolveDuration: null,
|
|
||||||
latestChatwootVersion: null,
|
|
||||||
showDeletePopup: false,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
validations: {
|
validations: {
|
||||||
@@ -49,14 +55,9 @@ export default {
|
|||||||
locale: {
|
locale: {
|
||||||
required,
|
required,
|
||||||
},
|
},
|
||||||
autoResolveDuration: {
|
|
||||||
minValue: minValue(1),
|
|
||||||
maxValue: maxValue(999),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
globalConfig: 'globalConfig/get',
|
|
||||||
getAccount: 'accounts/getAccount',
|
getAccount: 'accounts/getAccount',
|
||||||
uiFlags: 'accounts/getUIFlags',
|
uiFlags: 'accounts/getUIFlags',
|
||||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||||
@@ -68,16 +69,6 @@ export default {
|
|||||||
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
|
FEATURE_FLAGS.AUTO_RESOLVE_CONVERSATIONS
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
hasAnUpdateAvailable() {
|
|
||||||
if (!semver.valid(this.latestChatwootVersion)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return semver.lt(
|
|
||||||
this.globalConfig.appVersion,
|
|
||||||
this.latestChatwootVersion
|
|
||||||
);
|
|
||||||
},
|
|
||||||
languagesSortedByCode() {
|
languagesSortedByCode() {
|
||||||
const enabledLanguages = [...this.enabledLanguages];
|
const enabledLanguages = [...this.enabledLanguages];
|
||||||
return enabledLanguages.sort((l1, l2) =>
|
return enabledLanguages.sort((l1, l2) =>
|
||||||
@@ -87,51 +78,19 @@ export default {
|
|||||||
isUpdating() {
|
isUpdating() {
|
||||||
return this.uiFlags.isUpdating;
|
return this.uiFlags.isUpdating;
|
||||||
},
|
},
|
||||||
|
|
||||||
featureInboundEmailEnabled() {
|
featureInboundEmailEnabled() {
|
||||||
return !!this.features?.inbound_emails;
|
return !!this.features?.inbound_emails;
|
||||||
},
|
},
|
||||||
|
|
||||||
featureCustomReplyDomainEnabled() {
|
featureCustomReplyDomainEnabled() {
|
||||||
return (
|
return (
|
||||||
this.featureInboundEmailEnabled && !!this.features.custom_reply_domain
|
this.featureInboundEmailEnabled && !!this.features.custom_reply_domain
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
featureCustomReplyEmailEnabled() {
|
featureCustomReplyEmailEnabled() {
|
||||||
return (
|
return (
|
||||||
this.featureInboundEmailEnabled && !!this.features.custom_reply_email
|
this.featureInboundEmailEnabled && !!this.features.custom_reply_email
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
getAccountId() {
|
|
||||||
return this.id.toString();
|
|
||||||
},
|
|
||||||
confirmPlaceHolderText() {
|
|
||||||
return `${this.$t(
|
|
||||||
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.PLACE_HOLDER',
|
|
||||||
{
|
|
||||||
accountName: this.name,
|
|
||||||
}
|
|
||||||
)}`;
|
|
||||||
},
|
|
||||||
isMarkedForDeletion() {
|
|
||||||
const { custom_attributes = {} } = this.currentAccount;
|
|
||||||
return !!custom_attributes.marked_for_deletion_at;
|
|
||||||
},
|
|
||||||
markedForDeletionDate() {
|
|
||||||
const { custom_attributes = {} } = this.currentAccount;
|
|
||||||
if (!custom_attributes.marked_for_deletion_at) return null;
|
|
||||||
return new Date(custom_attributes.marked_for_deletion_at);
|
|
||||||
},
|
|
||||||
markedForDeletionReason() {
|
|
||||||
const { custom_attributes = {} } = this.currentAccount;
|
|
||||||
return custom_attributes.marked_for_deletion_reason || 'manual_deletion';
|
|
||||||
},
|
|
||||||
formattedDeletionDate() {
|
|
||||||
if (!this.markedForDeletionDate) return '';
|
|
||||||
return this.markedForDeletionDate.toLocaleString();
|
|
||||||
},
|
|
||||||
currentAccount() {
|
currentAccount() {
|
||||||
return this.getAccount(this.accountId) || {};
|
return this.getAccount(this.accountId) || {};
|
||||||
},
|
},
|
||||||
@@ -142,16 +101,8 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
async initializeAccount() {
|
async initializeAccount() {
|
||||||
try {
|
try {
|
||||||
const {
|
const { name, locale, id, domain, support_email, features } =
|
||||||
name,
|
this.getAccount(this.accountId);
|
||||||
locale,
|
|
||||||
id,
|
|
||||||
domain,
|
|
||||||
support_email,
|
|
||||||
features,
|
|
||||||
auto_resolve_duration,
|
|
||||||
latest_chatwoot_version: latestChatwootVersion,
|
|
||||||
} = this.getAccount(this.accountId);
|
|
||||||
|
|
||||||
this.$root.$i18n.locale = locale;
|
this.$root.$i18n.locale = locale;
|
||||||
this.name = name;
|
this.name = name;
|
||||||
@@ -160,8 +111,6 @@ export default {
|
|||||||
this.domain = domain;
|
this.domain = domain;
|
||||||
this.supportEmail = support_email;
|
this.supportEmail = support_email;
|
||||||
this.features = features;
|
this.features = features;
|
||||||
this.autoResolveDuration = auto_resolve_duration;
|
|
||||||
this.latestChatwootVersion = latestChatwootVersion;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore error
|
// Ignore error
|
||||||
}
|
}
|
||||||
@@ -179,7 +128,6 @@ export default {
|
|||||||
name: this.name,
|
name: this.name,
|
||||||
domain: this.domain,
|
domain: this.domain,
|
||||||
support_email: this.supportEmail,
|
support_email: this.supportEmail,
|
||||||
auto_resolve_duration: this.autoResolveDuration,
|
|
||||||
});
|
});
|
||||||
this.$root.$i18n.locale = this.locale;
|
this.$root.$i18n.locale = this.locale;
|
||||||
this.getAccount(this.id).locale = this.locale;
|
this.getAccount(this.id).locale = this.locale;
|
||||||
@@ -196,252 +144,101 @@ export default {
|
|||||||
rtl_view: isRTLSupported,
|
rtl_view: isRTLSupported,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
// Delete Function
|
|
||||||
openDeletePopup() {
|
|
||||||
this.showDeletePopup = true;
|
|
||||||
},
|
|
||||||
closeDeletePopup() {
|
|
||||||
this.showDeletePopup = false;
|
|
||||||
},
|
|
||||||
async markAccountForDeletion() {
|
|
||||||
this.closeDeletePopup();
|
|
||||||
try {
|
|
||||||
// Use the enterprise API to toggle deletion with delete action
|
|
||||||
await this.$store.dispatch('accounts/toggleDeletion', {
|
|
||||||
action_type: 'delete',
|
|
||||||
});
|
|
||||||
// Refresh account data
|
|
||||||
await this.$store.dispatch('accounts/get');
|
|
||||||
useAlert(this.$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SUCCESS'));
|
|
||||||
} catch (error) {
|
|
||||||
// Handle error message
|
|
||||||
this.handleDeletionError(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
handleDeletionError(error) {
|
|
||||||
const errorKey = error.response?.data?.error_key;
|
|
||||||
if (errorKey) {
|
|
||||||
useAlert(
|
|
||||||
this.$t(`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.${errorKey}`)
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const message = error.response?.data?.message;
|
|
||||||
if (message) {
|
|
||||||
useAlert(message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
useAlert(this.$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.FAILURE'));
|
|
||||||
},
|
|
||||||
async clearDeletionMark() {
|
|
||||||
try {
|
|
||||||
// Use the enterprise API to toggle deletion with undelete action
|
|
||||||
await this.$store.dispatch('accounts/toggleDeletion', {
|
|
||||||
action_type: 'undelete',
|
|
||||||
});
|
|
||||||
// Refresh account data
|
|
||||||
await this.$store.dispatch('accounts/get');
|
|
||||||
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
|
|
||||||
} catch (error) {
|
|
||||||
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.ERROR'));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col max-w-2xl mx-auto w-full">
|
||||||
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')">
|
<BaseSettingsHeader :title="$t('GENERAL_SETTINGS.TITLE')" />
|
||||||
<template #actions>
|
|
||||||
<V4Button blue :loading="isUpdating" @click="updateAccount">
|
|
||||||
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
|
|
||||||
</V4Button>
|
|
||||||
</template>
|
|
||||||
</BaseSettingsHeader>
|
|
||||||
<div class="flex-grow flex-shrink min-w-0 mt-3 overflow-auto">
|
<div class="flex-grow flex-shrink min-w-0 mt-3 overflow-auto">
|
||||||
<form v-if="!uiFlags.isFetchingItem" @submit.prevent="updateAccount">
|
<SectionLayout
|
||||||
<div
|
:title="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE')"
|
||||||
class="flex flex-row border-b border-slate-25 dark:border-slate-800"
|
:description="$t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE')"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
v-if="!uiFlags.isFetchingItem"
|
||||||
|
class="grid gap-4"
|
||||||
|
@submit.prevent="updateAccount"
|
||||||
>
|
>
|
||||||
<div
|
<WithLabel
|
||||||
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
|
:has-error="v$.name.$error"
|
||||||
|
:label="$t('GENERAL_SETTINGS.FORM.NAME.LABEL')"
|
||||||
|
:error-message="$t('GENERAL_SETTINGS.FORM.NAME.ERROR')"
|
||||||
>
|
>
|
||||||
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
|
<NextInput
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.TITLE') }}
|
v-model="name"
|
||||||
</h4>
|
type="text"
|
||||||
<p>{{ $t('GENERAL_SETTINGS.FORM.GENERAL_SECTION.NOTE') }}</p>
|
class="w-full"
|
||||||
</div>
|
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
|
||||||
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
|
@blur="v$.name.$touch"
|
||||||
<label :class="{ error: v$.name.$error }">
|
/>
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.NAME.LABEL') }}
|
</WithLabel>
|
||||||
<input
|
<WithLabel
|
||||||
v-model="name"
|
:has-error="v$.locale.$error"
|
||||||
type="text"
|
:label="$t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL')"
|
||||||
:placeholder="$t('GENERAL_SETTINGS.FORM.NAME.PLACEHOLDER')"
|
:error-message="$t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR')"
|
||||||
@blur="v$.name.$touch"
|
>
|
||||||
/>
|
<select v-model="locale" class="!mb-0 text-sm">
|
||||||
<span v-if="v$.name.$error" class="message">
|
<option
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.NAME.ERROR') }}
|
v-for="lang in languagesSortedByCode"
|
||||||
</span>
|
:key="lang.iso_639_1_code"
|
||||||
</label>
|
:value="lang.iso_639_1_code"
|
||||||
<label :class="{ error: v$.locale.$error }">
|
>
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.LABEL') }}
|
{{ lang.name }}
|
||||||
<select v-model="locale">
|
</option>
|
||||||
<option
|
</select>
|
||||||
v-for="lang in languagesSortedByCode"
|
</WithLabel>
|
||||||
:key="lang.iso_639_1_code"
|
<WithLabel
|
||||||
:value="lang.iso_639_1_code"
|
v-if="featureCustomReplyDomainEnabled"
|
||||||
>
|
:label="$t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL')"
|
||||||
{{ lang.name }}
|
>
|
||||||
</option>
|
<NextInput
|
||||||
</select>
|
v-model="domain"
|
||||||
<span v-if="v$.locale.$error" class="message">
|
type="text"
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.LANGUAGE.ERROR') }}
|
class="w-full"
|
||||||
</span>
|
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
|
||||||
</label>
|
/>
|
||||||
<label v-if="featureInboundEmailEnabled">
|
<template #help>
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED') }}
|
|
||||||
</label>
|
|
||||||
<label v-if="featureCustomReplyDomainEnabled">
|
|
||||||
{{
|
{{
|
||||||
|
featureInboundEmailEnabled &&
|
||||||
|
$t('GENERAL_SETTINGS.FORM.FEATURES.INBOUND_EMAIL_ENABLED')
|
||||||
|
}}
|
||||||
|
|
||||||
|
{{
|
||||||
|
featureCustomReplyDomainEnabled &&
|
||||||
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
|
$t('GENERAL_SETTINGS.FORM.FEATURES.CUSTOM_EMAIL_DOMAIN_ENABLED')
|
||||||
}}
|
}}
|
||||||
</label>
|
</template>
|
||||||
<label v-if="featureCustomReplyDomainEnabled">
|
</WithLabel>
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.DOMAIN.LABEL') }}
|
<WithLabel
|
||||||
<input
|
v-if="featureCustomReplyEmailEnabled"
|
||||||
v-model="domain"
|
:label="$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL')"
|
||||||
type="text"
|
>
|
||||||
:placeholder="$t('GENERAL_SETTINGS.FORM.DOMAIN.PLACEHOLDER')"
|
<NextInput
|
||||||
/>
|
v-model="supportEmail"
|
||||||
</label>
|
type="text"
|
||||||
<label v-if="featureCustomReplyEmailEnabled">
|
class="w-full"
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.LABEL') }}
|
:placeholder="
|
||||||
<input
|
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
|
||||||
v-model="supportEmail"
|
"
|
||||||
type="text"
|
/>
|
||||||
:placeholder="
|
</WithLabel>
|
||||||
$t('GENERAL_SETTINGS.FORM.SUPPORT_EMAIL.PLACEHOLDER')
|
<div>
|
||||||
"
|
<NextButton blue :is-loading="isUpdating" type="submit">
|
||||||
/>
|
{{ $t('GENERAL_SETTINGS.SUBMIT') }}
|
||||||
</label>
|
</NextButton>
|
||||||
<label
|
|
||||||
v-if="showAutoResolutionConfig"
|
|
||||||
:class="{ error: v$.autoResolveDuration.$error }"
|
|
||||||
>
|
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.LABEL') }}
|
|
||||||
<input
|
|
||||||
v-model="autoResolveDuration"
|
|
||||||
type="number"
|
|
||||||
:placeholder="
|
|
||||||
$t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.PLACEHOLDER')
|
|
||||||
"
|
|
||||||
@blur="v$.autoResolveDuration.$touch"
|
|
||||||
/>
|
|
||||||
<span v-if="v$.autoResolveDuration.$error" class="message">
|
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.AUTO_RESOLVE_DURATION.ERROR') }}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
</form>
|
</SectionLayout>
|
||||||
|
|
||||||
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
<woot-loading-state v-if="uiFlags.isFetchingItem" />
|
||||||
</div>
|
</div>
|
||||||
|
<AutoResolve v-if="showAutoResolutionConfig" />
|
||||||
<div class="flex flex-row">
|
<AccountId />
|
||||||
<div class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0">
|
|
||||||
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
|
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.TITLE') }}
|
|
||||||
</h4>
|
|
||||||
<p>
|
|
||||||
{{ $t('GENERAL_SETTINGS.FORM.ACCOUNT_ID.NOTE') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
|
|
||||||
<woot-code :script="getAccountId" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
|
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
|
||||||
<div
|
<AccountDelete />
|
||||||
class="flex flex-row pt-4 mt-2 border-t border-slate-25 dark:border-slate-800 text-black-900 dark:text-slate-300"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="flex-grow-0 flex-shrink-0 flex-[25%] min-w-0 py-4 pr-6 pl-0"
|
|
||||||
>
|
|
||||||
<h4 class="text-lg font-medium text-black-900 dark:text-slate-200">
|
|
||||||
{{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.TITLE') }}
|
|
||||||
</h4>
|
|
||||||
<p>
|
|
||||||
{{ $t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.NOTE') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 flex-grow-0 flex-shrink-0 flex-[50%]">
|
|
||||||
<div v-if="isMarkedForDeletion">
|
|
||||||
<div
|
|
||||||
class="p-4 flex-grow-0 flex-shrink-0 flex-[50%] bg-red-50 dark:bg-red-900 rounded"
|
|
||||||
>
|
|
||||||
<p class="mb-4">
|
|
||||||
{{
|
|
||||||
$t(
|
|
||||||
`GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.MESSAGE_${markedForDeletionReason === 'manual_deletion' ? 'MANUAL' : 'INACTIVITY'}`,
|
|
||||||
{
|
|
||||||
deletionDate: formattedDeletionDate,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
<NextButton
|
|
||||||
:label="
|
|
||||||
$t(
|
|
||||||
'GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.SCHEDULED_DELETION.CLEAR_BUTTON'
|
|
||||||
)
|
|
||||||
"
|
|
||||||
color="ruby"
|
|
||||||
:is-loading="uiFlags.isUpdating"
|
|
||||||
@click="clearDeletionMark"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="!isMarkedForDeletion">
|
|
||||||
<NextButton
|
|
||||||
:label="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.BUTTON_TEXT')"
|
|
||||||
color="ruby"
|
|
||||||
@click="openDeletePopup()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<WootConfirmDeleteModal
|
|
||||||
v-if="showDeletePopup"
|
|
||||||
v-model:show="showDeletePopup"
|
|
||||||
:title="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.TITLE')"
|
|
||||||
:message="$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.MESSAGE')"
|
|
||||||
:confirm-text="
|
|
||||||
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.BUTTON_TEXT')
|
|
||||||
"
|
|
||||||
:reject-text="
|
|
||||||
$t('GENERAL_SETTINGS.ACCOUNT_DELETE_SECTION.CONFIRM.DISMISS')
|
|
||||||
"
|
|
||||||
:confirm-value="name"
|
|
||||||
:confirm-place-holder-text="confirmPlaceHolderText"
|
|
||||||
@on-confirm="markAccountForDeletion"
|
|
||||||
@on-close="closeDeletePopup"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 text-sm text-center">
|
|
||||||
<div>{{ `v${globalConfig.appVersion}` }}</div>
|
|
||||||
<div v-if="hasAnUpdateAvailable && globalConfig.displayManifest">
|
|
||||||
{{
|
|
||||||
$t('GENERAL_SETTINGS.UPDATE_CHATWOOT', {
|
|
||||||
latestChatwootVersion: latestChatwootVersion,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div class="build-id">
|
|
||||||
<div>{{ `Build ${globalConfig.gitSha}` }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<BuildInfo />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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) => {
|
update: async ({ commit }, updateObj) => {
|
||||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
|
||||||
try {
|
try {
|
||||||
await AccountAPI.update('', updateObj);
|
const response = await AccountAPI.update('', updateObj);
|
||||||
|
commit(types.default.EDIT_ACCOUNT, response.data);
|
||||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
|
||||||
|
|||||||
@@ -39,10 +39,13 @@ describe('#actions', () => {
|
|||||||
|
|
||||||
describe('#update', () => {
|
describe('#update', () => {
|
||||||
it('sends correct actions if API is success', async () => {
|
it('sends correct actions if API is success', async () => {
|
||||||
axios.patch.mockResolvedValue();
|
axios.patch.mockResolvedValue({
|
||||||
|
data: { id: 1, name: 'John' },
|
||||||
|
});
|
||||||
await actions.update({ commit, getters }, accountData);
|
await actions.update({ commit, getters }, accountData);
|
||||||
expect(commit.mock.calls).toEqual([
|
expect(commit.mock.calls).toEqual([
|
||||||
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
|
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
|
||||||
|
[types.default.EDIT_ACCOUNT, { id: 1, name: 'John' }],
|
||||||
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
|
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,28 +1,12 @@
|
|||||||
<script>
|
<script setup>
|
||||||
export default {
|
defineProps({
|
||||||
props: {
|
label: { type: String, default: '' },
|
||||||
label: {
|
name: { type: String, required: true },
|
||||||
type: String,
|
icon: { type: String, default: '' },
|
||||||
default: '',
|
hasError: { type: Boolean, default: false },
|
||||||
},
|
helpMessage: { type: String, default: '' },
|
||||||
name: {
|
errorMessage: { type: String, default: '' },
|
||||||
type: String,
|
});
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
icon: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
hasError: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
errorMessage: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -30,8 +14,8 @@ export default {
|
|||||||
<label
|
<label
|
||||||
v-if="label"
|
v-if="label"
|
||||||
:for="name"
|
:for="name"
|
||||||
class="flex justify-between text-sm font-medium leading-6 text-slate-900 dark:text-white"
|
class="flex justify-between text-sm font-medium leading-6 text-n-slate-12"
|
||||||
:class="{ 'text-red-500': hasError }"
|
:class="{ 'text-n-ruby-12': hasError }"
|
||||||
>
|
>
|
||||||
<slot name="label">
|
<slot name="label">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
@@ -44,16 +28,24 @@ export default {
|
|||||||
v-if="icon"
|
v-if="icon"
|
||||||
size="16"
|
size="16"
|
||||||
:icon="icon"
|
:icon="icon"
|
||||||
class="absolute left-2 transform text-slate-400 dark:text-slate-600 w-5 h-5"
|
class="absolute left-2 transform text-n-slate-9 w-5 h-5"
|
||||||
/>
|
/>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<span
|
<div
|
||||||
v-if="errorMessage && hasError"
|
v-if="errorMessage && hasError"
|
||||||
class="text-xs text-n-ruby-9 dark:text-n-ruby-9 leading-2"
|
class="text-xs mt-2 ml-px text-n-ruby-9 leading-tight"
|
||||||
>
|
>
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</span>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="helpMessage || $slots.help"
|
||||||
|
class="text-xs mt-2 ml-px text-n-slate-10 leading-tight"
|
||||||
|
>
|
||||||
|
<slot name="help">
|
||||||
|
{{ helpMessage }}
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ class Account::ConversationsResolutionSchedulerJob < ApplicationJob
|
|||||||
queue_as :scheduled_jobs
|
queue_as :scheduled_jobs
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
Account.where.not(auto_resolve_duration: nil).all.find_each(batch_size: 100) do |account|
|
Account.with_auto_resolve.find_each(batch_size: 100) do |account|
|
||||||
Conversations::ResolutionJob.perform_later(account: account)
|
Conversations::ResolutionJob.perform_later(account: account)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ class Conversations::ResolutionJob < ApplicationJob
|
|||||||
|
|
||||||
def perform(account:)
|
def perform(account:)
|
||||||
# limiting the number of conversations to be resolved to avoid any performance issues
|
# limiting the number of conversations to be resolved to avoid any performance issues
|
||||||
resolvable_conversations = account.conversations.resolvable(account.auto_resolve_duration).limit(Limits::BULK_ACTIONS_LIMIT)
|
resolvable_conversations = account.conversations.resolvable(account.auto_resolve_after).limit(Limits::BULK_ACTIONS_LIMIT)
|
||||||
resolvable_conversations.each(&:toggle_status)
|
resolvable_conversations.each do |conversation|
|
||||||
|
# send message from bot that conversation has been resolved
|
||||||
|
# do this is account.auto_resolve_message is set
|
||||||
|
::MessageTemplates::Template::AutoResolve.new(conversation: conversation).perform if account.auto_resolve_message.present?
|
||||||
|
conversation.toggle_status
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# limits :jsonb
|
# limits :jsonb
|
||||||
# locale :integer default("en")
|
# locale :integer default("en")
|
||||||
# name :string not null
|
# name :string not null
|
||||||
|
# settings :jsonb
|
||||||
# status :integer default("active")
|
# status :integer default("active")
|
||||||
# support_email :string(100)
|
# support_email :string(100)
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
@@ -28,13 +29,28 @@ class Account < ApplicationRecord
|
|||||||
include Featurable
|
include Featurable
|
||||||
include CacheKeys
|
include CacheKeys
|
||||||
|
|
||||||
|
SETTINGS_PARAMS_SCHEMA = {
|
||||||
|
'type': 'object',
|
||||||
|
'properties':
|
||||||
|
{
|
||||||
|
'auto_resolve_after': { 'type': %w[integer null], 'minimum': 10, 'maximum': 1_439_856 },
|
||||||
|
'auto_resolve_message': { 'type': %w[string null] }
|
||||||
|
},
|
||||||
|
'required': [],
|
||||||
|
'additionalProperties': false
|
||||||
|
}.to_json.freeze
|
||||||
|
|
||||||
DEFAULT_QUERY_SETTING = {
|
DEFAULT_QUERY_SETTING = {
|
||||||
flag_query_mode: :bit_operator,
|
flag_query_mode: :bit_operator,
|
||||||
check_for_column: false
|
check_for_column: false
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
validates :auto_resolve_duration, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 999, allow_nil: true }
|
|
||||||
validates :domain, length: { maximum: 100 }
|
validates :domain, length: { maximum: 100 }
|
||||||
|
validates_with JsonSchemaValidator,
|
||||||
|
schema: SETTINGS_PARAMS_SCHEMA,
|
||||||
|
attribute_resolver: ->(record) { record.settings }
|
||||||
|
|
||||||
|
store_accessor :settings, :auto_resolve_after, :auto_resolve_message
|
||||||
|
|
||||||
has_many :account_users, dependent: :destroy_async
|
has_many :account_users, dependent: :destroy_async
|
||||||
has_many :agent_bot_inboxes, dependent: :destroy_async
|
has_many :agent_bot_inboxes, dependent: :destroy_async
|
||||||
@@ -83,6 +99,8 @@ class Account < ApplicationRecord
|
|||||||
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
|
enum locale: LANGUAGES_CONFIG.map { |key, val| [val[:iso_639_1_code], key] }.to_h
|
||||||
enum status: { active: 0, suspended: 1 }
|
enum status: { active: 0, suspended: 1 }
|
||||||
|
|
||||||
|
scope :with_auto_resolve, -> { where("(settings ->> 'auto_resolve_after')::int IS NOT NULL") }
|
||||||
|
|
||||||
before_validation :validate_limit_keys
|
before_validation :validate_limit_keys
|
||||||
after_create_commit :notify_creation
|
after_create_commit :notify_creation
|
||||||
after_destroy :remove_account_sequences
|
after_destroy :remove_account_sequences
|
||||||
|
|||||||
@@ -56,13 +56,24 @@ module ActivityMessageHandler
|
|||||||
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def auto_resolve_message_key(minutes)
|
||||||
|
if minutes >= 1440 && (minutes % 1440).zero?
|
||||||
|
{ key: 'auto_resolved_days', count: minutes / 1440 }
|
||||||
|
elsif minutes >= 60 && (minutes % 60).zero?
|
||||||
|
{ key: 'auto_resolved_hours', count: minutes / 60 }
|
||||||
|
else
|
||||||
|
{ key: 'auto_resolved_minutes', count: minutes }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def user_status_change_activity_content(user_name)
|
def user_status_change_activity_content(user_name)
|
||||||
if user_name
|
if user_name
|
||||||
I18n.t("conversations.activity.status.#{status}", user_name: user_name)
|
I18n.t("conversations.activity.status.#{status}", user_name: user_name)
|
||||||
elsif Current.contact.present? && resolved?
|
elsif Current.contact.present? && resolved?
|
||||||
I18n.t('conversations.activity.status.contact_resolved', contact_name: Current.contact.name.capitalize)
|
I18n.t('conversations.activity.status.contact_resolved', contact_name: Current.contact.name.capitalize)
|
||||||
elsif resolved?
|
elsif resolved?
|
||||||
I18n.t('conversations.activity.status.auto_resolved', duration: auto_resolve_duration)
|
message_data = auto_resolve_message_key(auto_resolve_after || 0)
|
||||||
|
I18n.t("conversations.activity.status.#{message_data[:key]}", count: message_data[:count])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class JsonSchemaValidator < ActiveModel::Validator
|
|||||||
|
|
||||||
def format_and_append_error(error, record)
|
def format_and_append_error(error, record)
|
||||||
return handle_required(error, record) if error['type'] == 'required'
|
return handle_required(error, record) if error['type'] == 'required'
|
||||||
|
return handle_minimum(error, record) if error['type'] == 'minimum'
|
||||||
|
return handle_maximum(error, record) if error['type'] == 'maximum'
|
||||||
|
|
||||||
type = error['type'] == 'object' ? 'hash' : error['type']
|
type = error['type'] == 'object' ? 'hash' : error['type']
|
||||||
|
|
||||||
@@ -74,6 +76,16 @@ class JsonSchemaValidator < ActiveModel::Validator
|
|||||||
record.errors.add(data, "must be of type #{expected_type}")
|
record.errors.add(data, "must be of type #{expected_type}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_minimum(error, record)
|
||||||
|
data = get_name_from_data_pointer(error)
|
||||||
|
record.errors.add(data, "must be greater than or equal to #{error['schema']['minimum']}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_maximum(error, record)
|
||||||
|
data = get_name_from_data_pointer(error)
|
||||||
|
record.errors.add(data, "must be less than or equal to #{error['schema']['maximum']}")
|
||||||
|
end
|
||||||
|
|
||||||
def get_name_from_data_pointer(error)
|
def get_name_from_data_pointer(error)
|
||||||
data = error['data_pointer']
|
data = error['data_pointer']
|
||||||
|
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ class Conversation < ApplicationRecord
|
|||||||
scope :assigned, -> { where.not(assignee_id: nil) }
|
scope :assigned, -> { where.not(assignee_id: nil) }
|
||||||
scope :assigned_to, ->(agent) { where(assignee_id: agent.id) }
|
scope :assigned_to, ->(agent) { where(assignee_id: agent.id) }
|
||||||
scope :unattended, -> { where(first_reply_created_at: nil).or(where.not(waiting_since: nil)) }
|
scope :unattended, -> { where(first_reply_created_at: nil).or(where.not(waiting_since: nil)) }
|
||||||
scope :resolvable, lambda { |auto_resolve_duration|
|
scope :resolvable, lambda { |auto_resolve_after|
|
||||||
return none if auto_resolve_duration.to_i.zero?
|
return none if auto_resolve_after.to_i.zero?
|
||||||
|
|
||||||
open.where('last_activity_at < ? ', Time.now.utc - auto_resolve_duration.days)
|
open.where('last_activity_at < ? AND waiting_since IS NULL', Time.now.utc - auto_resolve_after.minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
scope :last_user_message_at, lambda {
|
scope :last_user_message_at, lambda {
|
||||||
@@ -112,7 +112,7 @@ class Conversation < ApplicationRecord
|
|||||||
after_create_commit :notify_conversation_creation
|
after_create_commit :notify_conversation_creation
|
||||||
after_create_commit :load_attributes_created_by_db_triggers
|
after_create_commit :load_attributes_created_by_db_triggers
|
||||||
|
|
||||||
delegate :auto_resolve_duration, to: :account
|
delegate :auto_resolve_after, to: :account
|
||||||
|
|
||||||
def can_reply?
|
def can_reply?
|
||||||
Conversations::MessageWindowService.new(self).can_reply?
|
Conversations::MessageWindowService.new(self).can_reply?
|
||||||
|
|||||||
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
|
json.created_at resource.created_at
|
||||||
if resource.custom_attributes.present?
|
if resource.custom_attributes.present?
|
||||||
json.custom_attributes do
|
json.custom_attributes do
|
||||||
|
|||||||
@@ -159,7 +159,9 @@ en:
|
|||||||
open: 'Conversation was reopened by %{user_name}'
|
open: 'Conversation was reopened by %{user_name}'
|
||||||
pending: 'Conversation was marked as pending by %{user_name}'
|
pending: 'Conversation was marked as pending by %{user_name}'
|
||||||
snoozed: 'Conversation was snoozed by %{user_name}'
|
snoozed: 'Conversation was snoozed by %{user_name}'
|
||||||
auto_resolved: 'Conversation was marked resolved by system due to %{duration} days of inactivity'
|
auto_resolved_days: 'Conversation was marked resolved by system due to %{count} days of inactivity'
|
||||||
|
auto_resolved_hours: 'Conversation was marked resolved by system due to %{count} hours of inactivity'
|
||||||
|
auto_resolved_minutes: 'Conversation was marked resolved by system due to %{count} minutes of inactivity'
|
||||||
system_auto_open: System reopened the conversation due to a new incoming message.
|
system_auto_open: System reopened the conversation due to a new incoming message.
|
||||||
priority:
|
priority:
|
||||||
added: '%{user_name} set the priority to %{new_priority}'
|
added: '%{user_name} set the priority to %{new_priority}'
|
||||||
|
|||||||
@@ -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.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.0].define(version: 2025_04_16_182131) do
|
ActiveRecord::Schema[7.0].define(version: 2025_04_21_085134) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@@ -58,6 +58,7 @@ ActiveRecord::Schema[7.0].define(version: 2025_04_16_182131) do
|
|||||||
t.jsonb "custom_attributes", default: {}
|
t.jsonb "custom_attributes", default: {}
|
||||||
t.integer "status", default: 0
|
t.integer "status", default: 0
|
||||||
t.jsonb "internal_attributes", default: {}, null: false
|
t.jsonb "internal_attributes", default: {}, null: false
|
||||||
|
t.jsonb "settings", default: {}
|
||||||
t.index ["status"], name: "index_accounts_on_status"
|
t.index ["status"], name: "index_accounts_on_status"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
|
|
||||||
context 'when it is an authenticated user' do
|
context 'when it is an authenticated user' do
|
||||||
it 'shows an account' do
|
it 'shows an account' do
|
||||||
account.update(auto_resolve_duration: 30)
|
account.update(name: 'new name')
|
||||||
|
|
||||||
get "/api/v1/accounts/#{account.id}",
|
get "/api/v1/accounts/#{account.id}",
|
||||||
headers: admin.create_new_auth_token,
|
headers: admin.create_new_auth_token,
|
||||||
@@ -130,7 +130,6 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
expect(response.body).to include(account.locale)
|
expect(response.body).to include(account.locale)
|
||||||
expect(response.body).to include(account.domain)
|
expect(response.body).to include(account.domain)
|
||||||
expect(response.body).to include(account.support_email)
|
expect(response.body).to include(account.support_email)
|
||||||
expect(response.body).to include(account.auto_resolve_duration.to_s)
|
|
||||||
expect(response.body).to include(account.locale)
|
expect(response.body).to include(account.locale)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -189,7 +188,8 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
domain: 'example.com',
|
domain: 'example.com',
|
||||||
support_email: 'care@example.com',
|
support_email: 'care@example.com',
|
||||||
auto_resolve_duration: 40,
|
auto_resolve_after: 40,
|
||||||
|
auto_resolve_message: 'Auto resolved',
|
||||||
timezone: 'Asia/Kolkata',
|
timezone: 'Asia/Kolkata',
|
||||||
industry: 'Technology',
|
industry: 'Technology',
|
||||||
company_size: '1-10'
|
company_size: '1-10'
|
||||||
@@ -206,7 +206,10 @@ RSpec.describe 'Accounts API', type: :request do
|
|||||||
expect(account.reload.locale).to eq(params[:locale])
|
expect(account.reload.locale).to eq(params[:locale])
|
||||||
expect(account.reload.domain).to eq(params[:domain])
|
expect(account.reload.domain).to eq(params[:domain])
|
||||||
expect(account.reload.support_email).to eq(params[:support_email])
|
expect(account.reload.support_email).to eq(params[:support_email])
|
||||||
expect(account.reload.auto_resolve_duration).to eq(params[:auto_resolve_duration])
|
|
||||||
|
%w[auto_resolve_after auto_resolve_message].each do |attribute|
|
||||||
|
expect(account.reload.settings[attribute]).to eq(params[attribute.to_sym])
|
||||||
|
end
|
||||||
|
|
||||||
%w[timezone industry company_size].each do |attribute|
|
%w[timezone industry company_size].each do |attribute|
|
||||||
expect(account.reload.custom_attributes[attribute]).to eq(params[attribute.to_sym])
|
expect(account.reload.custom_attributes[attribute]).to eq(params[attribute.to_sym])
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'enqueues Conversations::ResolutionJob' do
|
it 'enqueues Conversations::ResolutionJob' do
|
||||||
account.update(auto_resolve_duration: 10)
|
account.update(auto_resolve_after: 10 * 60 * 24)
|
||||||
expect(Conversations::ResolutionJob).to receive(:perform_later).with(account: account).once
|
expect(Conversations::ResolutionJob).to receive(:perform_later).with(account: account).once
|
||||||
described_class.perform_now
|
described_class.perform_now
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,16 +18,17 @@ RSpec.describe Conversations::ResolutionJob do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves the issue if time of inactivity is more than the auto resolve duration' do
|
it 'resolves the issue if time of inactivity is more than the auto resolve duration' do
|
||||||
account.update(auto_resolve_duration: 10)
|
account.update(auto_resolve_after: 14_400) # 10 days in minutes
|
||||||
conversation.update(last_activity_at: 13.days.ago)
|
conversation.update(last_activity_at: 13.days.ago, waiting_since: nil)
|
||||||
described_class.perform_now(account: account)
|
described_class.perform_now(account: account)
|
||||||
expect(conversation.reload.status).to eq('resolved')
|
expect(conversation.reload.status).to eq('resolved')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolved only a limited number of conversations in a single execution' do
|
it 'resolved only a limited number of conversations in a single execution' do
|
||||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
||||||
account.update(auto_resolve_duration: 10)
|
account.update(auto_resolve_after: 14_400) # 10 days in minutes
|
||||||
create_list(:conversation, 3, account: account, last_activity_at: 13.days.ago)
|
conversations = create_list(:conversation, 3, account: account, last_activity_at: 13.days.ago)
|
||||||
|
conversations.each { |conversation| conversation.update(waiting_since: nil) }
|
||||||
described_class.perform_now(account: account)
|
described_class.perform_now(account: account)
|
||||||
expect(account.conversations.resolved.count).to eq(Limits::BULK_ACTIONS_LIMIT)
|
expect(account.conversations.resolved.count).to eq(Limits::BULK_ACTIONS_LIMIT)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Account do
|
RSpec.describe Account do
|
||||||
it { is_expected.to validate_numericality_of(:auto_resolve_duration).is_greater_than_or_equal_to(1) }
|
|
||||||
it { is_expected.to validate_numericality_of(:auto_resolve_duration).is_less_than_or_equal_to(999) }
|
|
||||||
|
|
||||||
it { is_expected.to have_many(:users).through(:account_users) }
|
it { is_expected.to have_many(:users).through(:account_users) }
|
||||||
it { is_expected.to have_many(:account_users) }
|
it { is_expected.to have_many(:account_users) }
|
||||||
it { is_expected.to have_many(:inboxes).dependent(:destroy_async) }
|
it { is_expected.to have_many(:inboxes).dependent(:destroy_async) }
|
||||||
@@ -134,4 +131,86 @@ RSpec.describe Account do
|
|||||||
expect(account.locale_english_name).to eq('portuguese')
|
expect(account.locale_english_name).to eq('portuguese')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'settings' do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
|
||||||
|
context 'when auto_resolve_after' do
|
||||||
|
it 'validates minimum value' do
|
||||||
|
account.settings = { auto_resolve_after: 4 }
|
||||||
|
expect(account).to be_invalid
|
||||||
|
expect(account.errors.messages).to eq({ auto_resolve_after: ['must be greater than or equal to 10'] })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'validates maximum value' do
|
||||||
|
account.settings = { auto_resolve_after: 1_439_857 }
|
||||||
|
expect(account).to be_invalid
|
||||||
|
expect(account.errors.messages).to eq({ auto_resolve_after: ['must be less than or equal to 1439856'] })
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows valid values' do
|
||||||
|
account.settings = { auto_resolve_after: 15 }
|
||||||
|
expect(account).to be_valid
|
||||||
|
|
||||||
|
account.settings = { auto_resolve_after: 1_439_856 }
|
||||||
|
expect(account).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows null values' do
|
||||||
|
account.settings = { auto_resolve_after: nil }
|
||||||
|
expect(account).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when auto_resolve_message' do
|
||||||
|
it 'allows string values' do
|
||||||
|
account.settings = { auto_resolve_message: 'This conversation has been resolved automatically.' }
|
||||||
|
expect(account).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows empty string' do
|
||||||
|
account.settings = { auto_resolve_message: '' }
|
||||||
|
expect(account).to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'allows nil values' do
|
||||||
|
account.settings = { auto_resolve_message: nil }
|
||||||
|
expect(account).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when using store_accessor' do
|
||||||
|
it 'correctly gets and sets auto_resolve_after' do
|
||||||
|
account.auto_resolve_after = 30
|
||||||
|
expect(account.auto_resolve_after).to eq(30)
|
||||||
|
expect(account.settings['auto_resolve_after']).to eq(30)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'correctly gets and sets auto_resolve_message' do
|
||||||
|
message = 'This conversation was automatically resolved'
|
||||||
|
account.auto_resolve_message = message
|
||||||
|
expect(account.auto_resolve_message).to eq(message)
|
||||||
|
expect(account.settings['auto_resolve_message']).to eq(message)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles nil values correctly' do
|
||||||
|
account.auto_resolve_after = nil
|
||||||
|
account.auto_resolve_message = nil
|
||||||
|
expect(account.auto_resolve_after).to be_nil
|
||||||
|
expect(account.auto_resolve_message).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when using with_auto_resolve scope' do
|
||||||
|
it 'finds accounts with auto_resolve_after set' do
|
||||||
|
account.update(auto_resolve_after: 40 * 24 * 60)
|
||||||
|
expect(described_class.with_auto_resolve).to include(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not find accounts without auto_resolve_after' do
|
||||||
|
account.update(auto_resolve_after: nil)
|
||||||
|
expect(described_class.with_auto_resolve).not_to include(account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ RSpec.describe JsonSchemaValidator, type: :validator do
|
|||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
'properties' => {
|
'properties' => {
|
||||||
'name' => { 'type' => 'string' },
|
'name' => { 'type' => 'string' },
|
||||||
'age' => { 'type' => 'integer' },
|
'age' => { 'type' => 'integer', 'minimum' => 18, 'maximum' => 100 },
|
||||||
'is_active' => { 'type' => 'boolean' },
|
'is_active' => { 'type' => 'boolean' },
|
||||||
'tags' => { 'type' => 'array' },
|
'tags' => { 'type' => 'array' },
|
||||||
|
'score' => { 'type' => 'number', 'minimum' => 0, 'maximum' => 10 },
|
||||||
'address' => {
|
'address' => {
|
||||||
'type' => 'object',
|
'type' => 'object',
|
||||||
'properties' => {
|
'properties' => {
|
||||||
@@ -109,4 +110,44 @@ RSpec.describe JsonSchemaValidator, type: :validator do
|
|||||||
:is_active => ['must be of type boolean'], :tags => ['must be of type array'] })
|
:is_active => ['must be of type boolean'], :tags => ['must be of type array'] })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with value below minimum' do
|
||||||
|
let(:invalid_data) do
|
||||||
|
{
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'age' => 15,
|
||||||
|
'score' => -1,
|
||||||
|
'is_active' => true
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails validation' do
|
||||||
|
model = TestModelForJSONValidation.new(invalid_data)
|
||||||
|
expect(model.valid?).to be false
|
||||||
|
expect(model.errors.messages).to eq({
|
||||||
|
:age => ['must be greater than or equal to 18'],
|
||||||
|
:score => ['must be greater than or equal to 0']
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with value above maximum' do
|
||||||
|
let(:invalid_data) do
|
||||||
|
{
|
||||||
|
'name' => 'John Doe',
|
||||||
|
'age' => 120,
|
||||||
|
'score' => 11,
|
||||||
|
'is_active' => true
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'fails validation' do
|
||||||
|
model = TestModelForJSONValidation.new(invalid_data)
|
||||||
|
expect(model.valid?).to be false
|
||||||
|
expect(model.errors.messages).to eq({
|
||||||
|
:age => ['must be less than or equal to 100'],
|
||||||
|
:score => ['must be less than or equal to 10']
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -213,11 +213,18 @@ RSpec.describe Conversation do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'adds a message for system auto resolution if marked resolved by system' do
|
it 'adds a message for system auto resolution if marked resolved by system' do
|
||||||
account.update(auto_resolve_duration: 40)
|
account.update(auto_resolve_after: 40 * 24 * 60)
|
||||||
conversation2 = create(:conversation, status: 'open', account: account, assignee: old_assignee)
|
conversation2 = create(:conversation, status: 'open', account: account, assignee: old_assignee)
|
||||||
Current.user = nil
|
Current.user = nil
|
||||||
|
|
||||||
system_resolved_message = "Conversation was marked resolved by system due to #{account.auto_resolve_duration} days of inactivity"
|
message_data = if account.auto_resolve_after >= 1440 && account.auto_resolve_after % 1440 == 0
|
||||||
|
{ key: 'auto_resolved_days', count: account.auto_resolve_after / 1440 }
|
||||||
|
elsif account.auto_resolve_after >= 60 && account.auto_resolve_after % 60 == 0
|
||||||
|
{ key: 'auto_resolved_hours', count: account.auto_resolve_after / 60 }
|
||||||
|
else
|
||||||
|
{ key: 'auto_resolved_minutes', count: account.auto_resolve_after }
|
||||||
|
end
|
||||||
|
system_resolved_message = "Conversation was marked resolved by system due to #{message_data[:count]} days of inactivity"
|
||||||
expect { conversation2.update(status: :resolved) }
|
expect { conversation2.update(status: :resolved) }
|
||||||
.to have_enqueued_job(Conversations::ActivityMessageJob)
|
.to have_enqueued_job(Conversations::ActivityMessageJob)
|
||||||
.with(conversation2, { account_id: conversation2.account_id, inbox_id: conversation2.inbox_id, message_type: :activity,
|
.with(conversation2, { account_id: conversation2.account_id, inbox_id: conversation2.inbox_id, message_type: :activity,
|
||||||
|
|||||||
Reference in New Issue
Block a user