diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index b4d5e3fc1..4fbe50902 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController before_action :check_authorization before_action :set_current_page, only: [:index, :active, :search, :filter] before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes] - before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update] + before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update] def index @contacts_count = resolved_contacts.count @@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController contacts = Current.account.contacts.where(id: ::OnlineStatusTracker .get_available_contact_ids(Current.account.id)) @contacts_count = contacts.count - @contacts = contacts.page(@current_page) + @contacts = fetch_contacts(contacts) end def show; end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index 8753918fc..e27869d82 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -124,6 +124,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro @conversation.save! end + def destroy + authorize @conversation, :destroy? + ::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip) + head :ok + end + private def permitted_update_params diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 4d675593f..773126755 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController end def settings_params - params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :auto_resolve_label) + params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label) end def check_signup_enabled diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index 4cb02d266..9aa73956a 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -71,6 +71,7 @@ class OauthCallbackController < ApplicationController def create_channel_with_inbox ActiveRecord::Base.transaction do channel_email = Channel::Email.create!(email: users_data['email'], account: account) + account.inboxes.create!( account: account, channel: channel_email, diff --git a/app/controllers/super_admin/account_users_controller.rb b/app/controllers/super_admin/account_users_controller.rb index b210dea19..d665b5684 100644 --- a/app/controllers/super_admin/account_users_controller.rb +++ b/app/controllers/super_admin/account_users_controller.rb @@ -2,6 +2,13 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController # Overwrite any of the RESTful controller actions to implement custom behavior # For example, you may want to send an email after a foo is updated. # + + # Since account/user page - account user role attribute links to the show page + # Handle with a redirect to the user show page + def show + redirect_to super_admin_user_path(requested_resource.user) + end + def create resource = resource_class.new(resource_params) authorize_resource(resource) diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 2eee3f484..025df2122 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -61,6 +61,11 @@ class ContactAPI extends ApiClient { return axios.get(requestURL); } + active(page = 1, sortAttr = 'name') { + let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`; + return axios.get(requestURL); + } + // eslint-disable-next-line default-param-last filter(page = 1, sortAttr = 'name', queryPayload) { let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`; diff --git a/app/javascript/dashboard/api/inbox/conversation.js b/app/javascript/dashboard/api/inbox/conversation.js index 1a87e6d96..0f539bfa9 100644 --- a/app/javascript/dashboard/api/inbox/conversation.js +++ b/app/javascript/dashboard/api/inbox/conversation.js @@ -137,6 +137,10 @@ class ConversationApi extends ApiClient { getInboxAssistant(conversationId) { return axios.get(`${this.url}/${conversationId}/inbox_assistant`); } + + delete(conversationId) { + return axios.delete(`${this.url}/${conversationId}`); + } } export default new ConversationApi(); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index b2b0dbfa0..0932a79c7 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -17,6 +17,7 @@ const props = defineProps({ additionalAttributes: { type: Object, default: () => ({}) }, phoneNumber: { type: String, default: '' }, thumbnail: { type: String, default: '' }, + availabilityStatus: { type: String, default: null }, isExpanded: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false }, }); @@ -92,7 +93,13 @@ const onClickViewDetails = () => emit('showContact', props.id); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index dbb417533..5d7aac3c3 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -235,6 +235,12 @@ const menuItems = computed(() => { ), activeOn: ['contacts_dashboard_index', 'contacts_edit'], }, + { + name: 'Active', + label: t('SIDEBAR.ACTIVE'), + to: accountScopedRoute('contacts_dashboard_active'), + activeOn: ['contacts_dashboard_active'], + }, { name: 'Segments', icon: 'i-lucide-group', diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 089d9e74f..098bc41c1 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -22,6 +22,7 @@ import { // https://tanstack.com/virtual/latest/docs/framework/vue/examples/variable import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller'; import ChatListHeader from './ChatListHeader.vue'; +import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import ConversationFilter from 'next/filter/ConversationFilter.vue'; import SaveCustomView from 'next/filter/SaveCustomView.vue'; import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; @@ -82,6 +83,7 @@ const emit = defineEmits(['conversationLoad']); const { uiSettings } = useUISettings(); const { t } = useI18n(); const router = useRouter(); +const route = useRoute(); const store = useStore(); const conversationListRef = ref(null); @@ -646,6 +648,30 @@ function openLastItemAfterDeleteInFolder() { } } +function redirectToConversationList() { + const { + params: { accountId, inbox_id: inboxId, label, teamId }, + name, + } = route; + + let conversationType = ''; + if (isOnMentionsView({ route: { name } })) { + conversationType = 'mention'; + } else if (isOnUnattendedView({ route: { name } })) { + conversationType = 'unattended'; + } + router.push( + conversationListPageURL({ + accountId, + conversationType: conversationType, + customViewId: props.foldersId, + inboxId, + label, + teamId, + }) + ); +} + async function assignPriority(priority, conversationId = null) { store.dispatch('setCurrentChatPriority', { priority, @@ -670,26 +696,7 @@ async function markAsUnread(conversationId) { await store.dispatch('markMessagesUnread', { id: conversationId, }); - const { - params: { accountId, inbox_id: inboxId, label, teamId }, - name, - } = useRoute(); - let conversationType = ''; - if (isOnMentionsView({ route: { name } })) { - conversationType = 'mention'; - } else if (isOnUnattendedView({ route: { name } })) { - conversationType = 'unattended'; - } - router.push( - conversationListPageURL({ - accountId, - conversationType: conversationType, - customViewId: props.foldersId, - inboxId, - label, - teamId, - }) - ); + redirectToConversationList(); } catch (error) { // Ignore error } @@ -703,6 +710,7 @@ async function markAsRead(conversationId) { // Ignore error } } + async function onAssignTeam(team, conversationId = null) { try { await store.dispatch('assignTeam', { @@ -764,6 +772,26 @@ onMounted(() => { } }); +const deleteConversationDialogRef = ref(null); +const selectedConversationId = ref(null); + +async function deleteConversation() { + try { + await store.dispatch('deleteConversation', selectedConversationId.value); + redirectToConversationList(); + selectedConversationId.value = null; + deleteConversationDialogRef.value.close(); + useAlert(t('CONVERSATION.SUCCESS_DELETE_CONVERSATION')); + } catch (error) { + useAlert(t('CONVERSATION.FAIL_DELETE_CONVERSATION')); + } +} + +const handleDelete = conversationId => { + selectedConversationId.value = conversationId; + deleteConversationDialogRef.value.open(); +}; + provide('selectConversation', selectConversation); provide('deSelectConversation', deSelectConversation); provide('assignAgent', onAssignAgent); @@ -775,6 +803,7 @@ provide('markAsUnread', markAsUnread); provide('markAsRead', markAsRead); provide('assignPriority', assignPriority); provide('isConversationSelected', isConversationSelected); +provide('deleteConversation', handleDelete); watch(activeTeam, () => resetAndFetchData()); @@ -938,6 +967,19 @@ watch(conversationFilters, (newVal, oldVal) => { + diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/contacts.js b/app/javascript/dashboard/components/layout/config/sidebarItems/contacts.js index e4150d47d..e5eb204ee 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/contacts.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/contacts.js @@ -9,6 +9,7 @@ const contacts = accountId => ({ 'contacts_edit', 'contacts_edit_segment', 'contacts_edit_label', + 'contacts_dashboard_active', ], menuItems: [ { @@ -18,6 +19,13 @@ const contacts = accountId => ({ toState: frontendURL(`accounts/${accountId}/contacts?page=1`), toStateName: 'contacts_dashboard_index', }, + { + icon: 'visitor-contacts', + label: 'ACTIVE', + hasSubMenu: false, + toState: frontendURL(`accounts/${accountId}/contacts/active`), + toStateName: 'contacts_dashboard_active', + }, ], }); diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue index 698690dad..4840b459f 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationCard.vue @@ -78,6 +78,7 @@ export default { 'markAsRead', 'assignPriority', 'updateConversationStatus', + 'deleteConversation', ], data() { return { @@ -237,6 +238,10 @@ export default { this.$emit('assignPriority', priority, this.chat.id); this.closeContextMenu(); }, + async deleteConversation() { + this.$emit('deleteConversation', this.chat.id); + this.closeContextMenu(); + }, }, }; @@ -363,6 +368,7 @@ export default { @mark-as-unread="markAsUnread" @mark-as-read="markAsRead" @assign-priority="assignPriority" + @delete-conversation="deleteConversation" /> diff --git a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue index fccff3457..6c788bbd2 100644 --- a/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue +++ b/app/javascript/dashboard/components/widgets/conversation/contextMenu/Index.vue @@ -8,6 +8,7 @@ import MenuItem from './menuItem.vue'; import MenuItemWithSubmenu from './menuItemWithSubmenu.vue'; import wootConstants from 'dashboard/constants/globals'; import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue'; +import { useAdmin } from 'dashboard/composables/useAdmin'; export default { components: { @@ -45,7 +46,14 @@ export default { 'assignAgent', 'assignTeam', 'assignLabel', + 'deleteConversation', ], + setup() { + const { isAdmin } = useAdmin(); + return { + isAdmin, + }; + }, data() { return { STATUS_TYPE: wootConstants.STATUS_TYPE, @@ -121,6 +129,11 @@ export default { icon: 'people-team-add', label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'), }, + deleteOption: { + key: 'delete', + icon: 'delete', + label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'), + }, }; }, computed: { @@ -178,6 +191,9 @@ export default { assignPriority(priority) { this.$emit('assignPriority', priority); }, + deleteConversation() { + this.$emit('deleteConversation', this.chatId); + }, show(key) { // If the conversation status is same as the action, then don't display the option // i.e.: Don't show an option to resolve if the conversation is already resolved. @@ -277,5 +293,13 @@ export default { @click.stop="$emit('assignTeam', team)" /> + diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 157ec46ae..9a99516e8 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -33,6 +33,14 @@ export const ARTICLE_EDITOR_MENU_OPTIONS = [ 'code', ]; +export const WIDGET_BUILDER_EDITOR_MENU_OPTIONS = [ + 'strong', + 'em', + 'link', + 'undo', + 'redo', +]; + export const MESSAGE_EDITOR_IMAGE_RESIZES = [ { name: 'Small', diff --git a/app/javascript/dashboard/helper/auditlogHelper.js b/app/javascript/dashboard/helper/auditlogHelper.js index 6f5a4dede..706d09ba2 100644 --- a/app/javascript/dashboard/helper/auditlogHelper.js +++ b/app/javascript/dashboard/helper/auditlogHelper.js @@ -36,6 +36,7 @@ const translationKeys = { 'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`, 'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`, 'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`, + 'conversation:destroy': `AUDIT_LOGS.CONVERSATION.DELETE`, }; function extractAttrChange(attrChange) { @@ -168,6 +169,11 @@ export function generateTranslationPayload(auditLogItem, agentList) { const auditableType = auditLogItem.auditable_type.toLowerCase(); const action = auditLogItem.action.toLowerCase(); + if (auditableType === 'conversation' && action === 'destroy') { + translationPayload.id = + auditLogItem.audited_changes?.display_id || auditLogItem.auditable_id; + } + if (auditableType === 'accountuser') { translationPayload = handleAccountUser( auditLogItem, diff --git a/app/javascript/dashboard/i18n/locale/en/auditLogs.json b/app/javascript/dashboard/i18n/locale/en/auditLogs.json index 8194c667c..f85ad2a3e 100644 --- a/app/javascript/dashboard/i18n/locale/en/auditLogs.json +++ b/app/javascript/dashboard/i18n/locale/en/auditLogs.json @@ -69,6 +69,9 @@ }, "ACCOUNT": { "EDIT": "{agentName} updated the account configuration (#{id})" + }, + "CONVERSATION": { + "DELETE": "{agentName} deleted conversation #{id}" } } } diff --git a/app/javascript/dashboard/i18n/locale/en/contact.json b/app/javascript/dashboard/i18n/locale/en/contact.json index 4c599ffe8..4f0a27cae 100644 --- a/app/javascript/dashboard/i18n/locale/en/contact.json +++ b/app/javascript/dashboard/i18n/locale/en/contact.json @@ -286,6 +286,7 @@ "HEADER": { "TITLE": "Contacts", "SEARCH_TITLE": "Search contacts", + "ACTIVE_TITLE": "Active contacts", "SEARCH_PLACEHOLDER": "Search...", "MESSAGE_BUTTON": "Message", "SEND_MESSAGE": "Send message", @@ -560,7 +561,8 @@ "SUBTITLE": "Start adding new contacts by clicking on the button below", "BUTTON_LABEL": "Add contact", "SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍", - "LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋" + "LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋", + "ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙" } }, diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 26ec6dc16..fdb7fc07a 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -118,6 +118,11 @@ "FAILED": "Couldn't change priority. Please try again." } }, + "DELETE_CONVERSATION": { + "TITLE": "Delete conversation #{conversationId}", + "DESCRIPTION": "Are you sure you want to delete this conversation?", + "CONFIRM": "Delete" + }, "CARD_CONTEXT_MENU": { "PENDING": "Mark as pending", "RESOLVED": "Mark as resolved", @@ -134,6 +139,7 @@ "ASSIGN_LABEL": "Assign label", "AGENTS_LOADING": "Loading agents...", "ASSIGN_TEAM": "Assign team", + "DELETE": "Delete conversation", "API": { "AGENT_ASSIGNMENT": { "SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"", @@ -208,6 +214,8 @@ "ASSIGN_LABEL_SUCCESFUL": "Label assigned successfully", "ASSIGN_LABEL_FAILED": "Label assignment failed", "CHANGE_TEAM": "Conversation team changed", + "SUCCESS_DELETE_CONVERSATION": "Conversation deleted successfully", + "FAIL_DELETE_CONVERSATION": "Couldn't delete conversation! Try again", "FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE} MB attachment limit", "MESSAGE_ERROR": "Unable to send this message, please try again later", "SENT_BY": "Sent by:", diff --git a/app/javascript/dashboard/i18n/locale/en/generalSettings.json b/app/javascript/dashboard/i18n/locale/en/generalSettings.json index d243c0583..c0c4d247d 100644 --- a/app/javascript/dashboard/i18n/locale/en/generalSettings.json +++ b/app/javascript/dashboard/i18n/locale/en/generalSettings.json @@ -92,6 +92,32 @@ "PLACEHOLDER": "Your company's support email", "ERROR": "" }, + "AUTO_RESOLVE_IGNORE_WAITING": { + "LABEL": "Exclude unattended conversations", + "HELP": "When enabled, the system will skip resolving conversations that are still waiting for an agent's reply." + }, + "AUDIO_TRANSCRIPTION": { + "TITLE": "Transcribe Audio Messages", + "NOTE": "Automatically transcribe audio messages in conversations. Generate a text transcript whenever an audio message is sent or received, and display it alongside the message.", + "API": { + "SUCCESS": "Audio transcription setting updated successfully", + "ERROR": "Failed to update audio transcription setting" + } + }, + "AUTO_RESOLVE_DURATION": { + "LABEL": "Inactivity duration for resolution", + "HELP": "Duration after a conversation should auto resolve if there is no activity", + "PLACEHOLDER": "30", + "ERROR": "Auto resolve duration should be between 10 minutes and 999 days", + "API": { + "SUCCESS": "Auto resolve settings updated successfully", + "ERROR": "Failed to update auto resolve settings" + }, + "UPDATE_BUTTON": "Update", + "MESSAGE_LABEL": "Custom resolution message", + "MESSAGE_PLACEHOLDER": "Conversation was marked resolved by system due to 15 days of inactivity", + "MESSAGE_HELP": "This message is sent to the customer when a conversation is automatically resolved by the system due to inactivity." + }, "FEATURES": { "INBOUND_EMAIL_ENABLED": "Conversation continuity with emails is enabled for your account.", "CUSTOM_EMAIL_DOMAIN_ENABLED": "You can receive emails in your custom domain now." diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 81b9c8a78..219041d75 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -286,6 +286,7 @@ "REPORTS": "Reports", "SETTINGS": "Settings", "CONTACTS": "Contacts", + "ACTIVE": "Active", "CAPTAIN": "Captain", "CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_DOCUMENTS": "Documents", diff --git a/app/javascript/dashboard/modules/widget-preview/components/WidgetHead.vue b/app/javascript/dashboard/modules/widget-preview/components/WidgetHead.vue index 4881c2a0a..5de9ca069 100644 --- a/app/javascript/dashboard/modules/widget-preview/components/WidgetHead.vue +++ b/app/javascript/dashboard/modules/widget-preview/components/WidgetHead.vue @@ -1,5 +1,6 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue index d9551c427..a3bb9f3f4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/Settings.vue @@ -23,6 +23,8 @@ import { FEATURE_FLAGS } from '../../../../featureFlags'; import SenderNameExamplePreview from './components/SenderNameExamplePreview.vue'; import NextButton from 'dashboard/components-next/button/Button.vue'; import { INBOX_TYPES } from 'dashboard/helper/inbox'; +import { WIDGET_BUILDER_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor'; +import Editor from 'dashboard/components-next/Editor/Editor.vue'; export default { components: { @@ -43,6 +45,7 @@ export default { NextButton, InstagramReauthorize, DuplicateInboxBanner, + Editor, }, mixins: [inboxMixin], setup() { @@ -70,6 +73,7 @@ export default { selectedTabIndex: 0, selectedPortalSlug: '', showBusinessNameInput: false, + welcomeTaglineEditorMenuOptions: WIDGET_BUILDER_EDITOR_MENU_OPTIONS, }; }, computed: { @@ -480,10 +484,10 @@ export default { " /> -