diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 43dc1c7dc..9472dc623 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -56,7 +56,6 @@ class ConversationFinder filter_by_team if @team filter_by_labels if params[:labels] filter_by_query if params[:q] - filter_by_reply_status end def set_inboxes @@ -76,15 +75,9 @@ class ConversationFinder end def find_all_conversations - case params[:conversation_type] - when 'mention' - conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id) - @conversations = current_account.conversations.where(id: conversation_ids) - when 'participating' - @conversations = current_user.participating_conversations.where(account_id: current_account.id) - else - @conversations = current_account.conversations.where(inbox_id: @inbox_ids) - end + @conversations = current_account.conversations.where(inbox_id: @inbox_ids) + filter_by_conversation_type if params[:conversation_type] + @conversations end def filter_by_assignee_type @@ -99,8 +92,17 @@ class ConversationFinder @conversations end - def filter_by_reply_status - @conversations = @conversations.where(first_reply_created_at: nil) if params[:reply_status] == 'unattended' + def filter_by_conversation_type + case @params[:conversation_type] + when 'mention' + conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id) + @conversations = @conversations.where(id: conversation_ids) + when 'participating' + @conversations = current_user.participating_conversations.where(account_id: current_account.id) + when 'unattended' + @conversations = @conversations.where(first_reply_created_at: nil) + end + @conversations end def filter_by_query diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index b2ae291f0..2177b715a 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -335,10 +335,8 @@ export default { status: this.activeStatus, page: this.currentPage + 1, labels: this.label ? [this.label] : undefined, - teamId: this.teamId ? this.teamId : undefined, - conversationType: this.conversationType - ? this.conversationType - : undefined, + teamId: this.teamId || undefined, + conversationType: this.conversationType || undefined, folders: this.hasActiveFolders ? this.savedFoldersValue : undefined, }; }, @@ -358,6 +356,9 @@ export default { if (this.conversationType === 'participating') { return this.$t('CONVERSATION_WATCHERS.SIDEBAR_MENU_TITLE'); } + if (this.conversationType === 'unattended') { + return this.$t('CHAT_LIST.UNATTENDED_HEADING'); + } if (this.hasActiveFolders) { return this.activeFolder.name; } diff --git a/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js b/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js index 8288beedc..9e4982670 100644 --- a/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js +++ b/app/javascript/dashboard/components/layout/config/sidebarItems/conversations.js @@ -18,6 +18,8 @@ const conversations = accountId => ({ 'conversation_through_participating', 'folder_conversations', 'conversations_through_folders', + 'conversation_unattended', + 'conversation_through_unattended', ], menuItems: [ { @@ -41,6 +43,14 @@ const conversations = accountId => ({ key: 'conversation_participating', toState: frontendURL(`accounts/${accountId}/participating/conversations`), toStateName: 'conversation_participating', + }, + { + icon: 'mail-unread', + label: 'UNATTENDED_CONVERSATIONS', + key: 'conversation_unattended', + toState: frontendURL(`accounts/${accountId}/unattended/conversations`), + toStateName: 'conversation_unattended', + }, ], }); diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 3e2dc81a0..e7c825752 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -450,8 +450,7 @@ export default { return this.currentChat.id; }, conversationIdByRoute() { - const { conversation_id: conversationId } = this.$route.params; - return conversationId; + return this.conversationId; }, editorStateId() { return `draft-${this.conversationIdByRoute}-${this.replyType}`; diff --git a/app/javascript/dashboard/helper/URLHelper.js b/app/javascript/dashboard/helper/URLHelper.js index aed253085..fcb2eab7f 100644 --- a/app/javascript/dashboard/helper/URLHelper.js +++ b/app/javascript/dashboard/helper/URLHelper.js @@ -58,6 +58,8 @@ export const conversationUrl = ({ url = `accounts/${accountId}/mentions/conversations/${id}`; } else if (conversationType === 'participating') { url = `accounts/${accountId}/participating/conversations/${id}`; + } else if (conversationType === 'unattended') { + url = `accounts/${accountId}/unattended/conversations/${id}`; } return url; }; diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index 5332acc00..4713bd611 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -8,6 +8,7 @@ }, "TAB_HEADING": "Conversations", "MENTION_HEADING": "Mentions", + "UNATTENDED_HEADING": "Unattended", "SEARCH": { "INPUT": "Search for People, Chats, Saved Replies .." }, diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 9ceb19418..b64adb62a 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -178,6 +178,7 @@ "ALL_CONVERSATIONS": "All Conversations", "MENTIONED_CONVERSATIONS": "Mentions", "PARTICIPATING_CONVERSATIONS": "Participating", + "UNATTENDED_CONVERSATIONS": "Unattended", "REPORTS": "Reports", "SETTINGS": "Settings", "CONTACTS": "Contacts", diff --git a/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js b/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js index 811d0b91b..0f3ec5a44 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js +++ b/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js @@ -133,11 +133,24 @@ export default { 'accounts/:accountId/participating/conversations/:conversationId' ), name: 'conversation_through_participating', + + { + path: frontendURL('accounts/:accountId/unattended/conversations'), + name: 'conversation_unattended', + roles: ['administrator', 'agent'], + component: ConversationView, + props: () => ({ conversationType: 'unattended' }), + }, + { + path: frontendURL( + 'accounts/:accountId/unattended/conversations/:conversationId' + ), + name: 'conversation_through_unattended', roles: ['administrator', 'agent'], component: ConversationView, props: route => ({ conversationId: route.params.conversationId, - conversationType: 'participating', + conversationType: 'unattended', }), }, ], diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js index 86cc2c998..299bec813 100644 --- a/app/javascript/dashboard/store/modules/conversations/actions.js +++ b/app/javascript/dashboard/store/modules/conversations/actions.js @@ -7,6 +7,7 @@ import { createPendingMessage } from 'dashboard/helper/commons'; import { buildConversationList, isOnMentionsView, + isOnUnattendedView, } from './helpers/actionHelpers'; import messageReadActions from './actions/messageReadActions'; // actions @@ -230,6 +231,7 @@ const actions = { if ( !hasAppliedFilters && !isOnMentionsView(rootState) && + !isOnUnattendedView(rootState) && isMatchingInboxFilter ) { commit(types.ADD_CONVERSATION, conversation); @@ -243,6 +245,12 @@ const actions = { } }, + addUnattended({ dispatch, rootState }, conversation) { + if (isOnUnattendedView(rootState)) { + dispatch('updateConversation', conversation); + } + }, + updateConversation({ commit, dispatch }, conversation) { const { meta: { sender }, diff --git a/app/javascript/dashboard/store/modules/conversations/helpers.js b/app/javascript/dashboard/store/modules/conversations/helpers.js index be136af54..7996a34ab 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers.js @@ -5,33 +5,54 @@ export const findPendingMessageIndex = (chat, message) => { ); }; -const filterByStatus = (chatStatus, filterStatus) => +export const filterByStatus = (chatStatus, filterStatus) => filterStatus === 'all' ? true : chatStatus === filterStatus; +export const filterByInbox = (shouldFilter, inboxId, chatInboxId) => { + const isOnInbox = Number(inboxId) === chatInboxId; + return inboxId ? isOnInbox && shouldFilter : shouldFilter; +}; + +export const filterByTeam = (shouldFilter, teamId, chatTeamId) => { + const isOnTeam = Number(teamId) === chatTeamId; + return teamId ? isOnTeam && shouldFilter : shouldFilter; +}; + +export const filterByLabel = (shouldFilter, labels, chatLabels) => { + const isOnLabel = labels.every(label => chatLabels.includes(label)); + return labels.length ? isOnLabel && shouldFilter : shouldFilter; +}; +export const filterByUnattended = ( + shouldFilter, + conversationType, + firstReplyOn +) => { + return conversationType === 'unattended' + ? !firstReplyOn && shouldFilter + : shouldFilter; +}; + export const applyPageFilters = (conversation, filters) => { - const { inboxId, status, labels = [], teamId } = filters; + const { inboxId, status, labels = [], teamId, conversationType } = filters; const { status: chatStatus, inbox_id: chatInboxId, labels: chatLabels = [], meta = {}, + first_reply_created_at: firstReplyOn, } = conversation; const team = meta.team || {}; const { id: chatTeamId } = team; let shouldFilter = filterByStatus(chatStatus, status); - if (inboxId) { - const filterByInbox = Number(inboxId) === chatInboxId; - shouldFilter = shouldFilter && filterByInbox; - } - if (teamId) { - const filterByTeam = Number(teamId) === chatTeamId; - shouldFilter = shouldFilter && filterByTeam; - } - if (labels.length) { - const filterByLabels = labels.every(label => chatLabels.includes(label)); - shouldFilter = shouldFilter && filterByLabels; - } + shouldFilter = filterByInbox(shouldFilter, inboxId, chatInboxId); + shouldFilter = filterByTeam(shouldFilter, teamId, chatTeamId); + shouldFilter = filterByLabel(shouldFilter, labels, chatLabels); + shouldFilter = filterByUnattended( + shouldFilter, + conversationType, + firstReplyOn + ); return shouldFilter; }; diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js index 57c50c619..88ee39fd8 100644 --- a/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js +++ b/app/javascript/dashboard/store/modules/conversations/helpers/actionHelpers.js @@ -22,6 +22,14 @@ export const isOnMentionsView = ({ route: { name: routeName } }) => { return MENTION_ROUTES.includes(routeName); }; +export const isOnUnattendedView = ({ route: { name: routeName } }) => { + const UNATTENDED_ROUTES = [ + 'conversation_unattended', + 'conversation_through_unattended', + ]; + return UNATTENDED_ROUTES.includes(routeName); +}; + export const buildConversationList = ( context, requestPayload, diff --git a/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js index af25f5527..b0a3cdab3 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/helpers.spec.js @@ -1,6 +1,10 @@ import { findPendingMessageIndex, applyPageFilters, + filterByInbox, + filterByTeam, + filterByLabel, + filterByUnattended, } from '../../conversations/helpers'; const conversationList = [ @@ -119,3 +123,52 @@ describe('#applyPageFilters', () => { }); }); }); + +describe('#filterByInbox', () => { + it('returns true if conversation has inbox filter active', () => { + const inboxId = '1'; + const chatInboxId = 1; + expect(filterByInbox(true, inboxId, chatInboxId)).toEqual(true); + }); + it('returns false if inbox filter is not active', () => { + const inboxId = '1'; + const chatInboxId = 13; + expect(filterByInbox(true, inboxId, chatInboxId)).toEqual(false); + }); +}); + +describe('#filterByTeam', () => { + it('returns true if conversation has team and team filter is active', () => { + const [teamId, chatTeamId] = ['1', 1]; + expect(filterByTeam(true, teamId, chatTeamId)).toEqual(true); + }); + it('returns false if team filter is not active', () => { + const [teamId, chatTeamId] = ['1', 12]; + expect(filterByTeam(true, teamId, chatTeamId)).toEqual(false); + }); +}); + +describe('#filterByLabel', () => { + it('returns true if conversation has labels and labels filter is active', () => { + const labels = ['dev', 'cs']; + const chatLabels = ['dev', 'cs', 'sales']; + expect(filterByLabel(true, labels, chatLabels)).toEqual(true); + }); + it('returns false if conversation has not all labels', () => { + const labels = ['dev', 'cs', 'sales']; + const chatLabels = ['cs', 'sales']; + expect(filterByLabel(true, labels, chatLabels)).toEqual(false); + }); +}); + +describe('#filterByUnattended', () => { + it('returns true if conversation type is unattended and has no first reply', () => { + expect(filterByUnattended(true, 'unattended', undefined)).toEqual(true); + }); + it('returns false if conversation type is not unattended and has no first reply', () => { + expect(filterByUnattended(false, 'mentions', undefined)).toEqual(false); + }); + it('returns true if conversation type is unattended and has first reply', () => { + expect(filterByUnattended(true, 'mentions', 123)).toEqual(true); + }); +}); diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json index 4b8a4fcf6..3be852cfe 100644 --- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json +++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json @@ -110,6 +110,7 @@ "location-outline": "M5.843 4.568a8.707 8.707 0 1 1 12.314 12.314l-1.187 1.174c-.875.858-2.01 1.962-3.406 3.312a2.25 2.25 0 0 1-3.128 0l-3.491-3.396c-.439-.431-.806-.794-1.102-1.09a8.707 8.707 0 0 1 0-12.314Zm11.253 1.06A7.207 7.207 0 1 0 6.904 15.822L8.39 17.29a753.98 753.98 0 0 0 3.088 3 .75.75 0 0 0 1.043 0l3.394-3.3c.47-.461.863-.85 1.18-1.168a7.207 7.207 0 0 0 0-10.192ZM12 7.999a3.002 3.002 0 1 1 0 6.004 3.002 3.002 0 0 1 0-6.003Zm0 1.5a1.501 1.501 0 1 0 0 3.004 1.501 1.501 0 0 0 0-3.003Z", "lock-closed-outline": "M12 2a4 4 0 0 1 4 4v2h1.75A2.25 2.25 0 0 1 20 10.25v9.5A2.25 2.25 0 0 1 17.75 22H6.25A2.25 2.25 0 0 1 4 19.75v-9.5A2.25 2.25 0 0 1 6.25 8H8V6a4 4 0 0 1 4-4Zm5.75 7.5H6.25a.75.75 0 0 0-.75.75v9.5c0 .414.336.75.75.75h11.5a.75.75 0 0 0 .75-.75v-9.5a.75.75 0 0 0-.75-.75Zm-5.75 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm0-10A2.5 2.5 0 0 0 9.5 6v2h5V6A2.5 2.5 0 0 0 12 3.5Z", "mail-inbox-all-outline": "M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3Zm2.075 11.5H4.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188Zm9.425-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Zm-11 5h10.5a.75.75 0 0 1 .102 1.493L17.25 11H6.75a.75.75 0 0 1-.102-1.493L6.75 9.5h10.5-10.5Zm0-3h10.5a.75.75 0 0 1 .102 1.493L17.25 8H6.75a.75.75 0 0 1-.102-1.493L6.75 6.5h10.5-10.5Z", + "mail-unread-outline": "M16 6.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 12.153l6.03-3.174a3.489 3.489 0 0 0 2.97.985v6.786a3.25 3.25 0 0 1-3.066 3.245L16.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 5h11.087A3.487 3.487 0 0 0 16 6.5Zm2.5 3.399-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 9.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.899ZM19.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Z", "mail-outline": "M5.25 4h13.5a3.25 3.25 0 0 1 3.245 3.066L22 7.25v9.5a3.25 3.25 0 0 1-3.066 3.245L18.75 20H5.25a3.25 3.25 0 0 1-3.245-3.066L2 16.75v-9.5a3.25 3.25 0 0 1 3.066-3.245L5.25 4h13.5-13.5ZM20.5 9.373l-8.15 4.29a.75.75 0 0 1-.603.043l-.096-.042L3.5 9.374v7.376a1.75 1.75 0 0 0 1.606 1.744l.144.006h13.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V9.373ZM18.75 5.5H5.25a1.75 1.75 0 0 0-1.744 1.606L3.5 7.25v.429l8.5 4.473 8.5-4.474V7.25a1.75 1.75 0 0 0-1.607-1.744L18.75 5.5Z", "map-outline": "m9.203 4 .047-.002.046.001a.73.73 0 0 1 .067.007l.016.004c.086.014.17.044.252.092l.051.034 5.07 3.565L19.82 4.14a.75.75 0 0 1 1.174.51l.007.104v10.632a.75.75 0 0 1-.238.548l-.08.066-5.5 3.866a.744.744 0 0 1-.828.023L9.25 16.297l-5.07 3.565a.75.75 0 0 1-1.174-.51l-.007-.104V8.616a.75.75 0 0 1 .238-.548l.08-.066 5.5-3.866a.762.762 0 0 1 .2-.101l.122-.028.064-.008Zm10.298 2.197-4 2.812v8.799l4-2.812v-8.8ZM8.5 6.193l-4 2.812v8.8l4-2.813V6.193Zm1.502 0v8.8l4 2.811V9.005l-4-2.812Z", "megaphone-outline": "M21.907 5.622c.062.208.093.424.093.641V17.74a2.25 2.25 0 0 1-2.891 2.156l-5.514-1.64a4.002 4.002 0 0 1-7.59-1.556L6 16.5l-.001-.5-2.39-.711A2.25 2.25 0 0 1 2 13.131V10.87a2.25 2.25 0 0 1 1.61-2.156l15.5-4.606a2.25 2.25 0 0 1 2.797 1.515ZM7.499 16.445l.001.054a2.5 2.5 0 0 0 4.624 1.321l-4.625-1.375Zm12.037-10.9-15.5 4.605a.75.75 0 0 0-.536.72v2.261a.75.75 0 0 0 .536.72l15.5 4.607a.75.75 0 0 0 .964-.72V6.264a.75.75 0 0 0-.964-.719Z", diff --git a/app/listeners/reporting_event_listener.rb b/app/listeners/reporting_event_listener.rb index 3a787e417..e84d778bd 100644 --- a/app/listeners/reporting_event_listener.rb +++ b/app/listeners/reporting_event_listener.rb @@ -36,9 +36,9 @@ class ReportingEventListener < BaseListener event_start_time: conversation.created_at, event_end_time: message.created_at ) - # rubocop:disable Rails/SkipsModelValidations - conversation.update_columns(first_reply_created_at: message.created_at) - # rubocop:enable Rails/SkipsModelValidations + + conversation.update(first_reply_created_at: message.created_at) + reporting_event.save! end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 84a54e1c1..f051a7e0e 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -220,7 +220,7 @@ class Conversation < ApplicationRecord def notify_conversation_updation return unless previous_changes.keys.present? && (previous_changes.keys & %w[team_id assignee_id status snoozed_until - custom_attributes label_list]).present? + custom_attributes label_list first_reply_created_at]).present? dispatcher_dispatch(CONVERSATION_UPDATED, previous_changes) end diff --git a/app/presenters/conversations/event_data_presenter.rb b/app/presenters/conversations/event_data_presenter.rb index d7e170bfa..2bd9392be 100644 --- a/app/presenters/conversations/event_data_presenter.rb +++ b/app/presenters/conversations/event_data_presenter.rb @@ -14,6 +14,7 @@ class Conversations::EventDataPresenter < SimpleDelegator custom_attributes: custom_attributes, snoozed_until: snoozed_until, unread_count: unread_incoming_messages.count, + first_reply_created_at: first_reply_created_at, **push_timestamps } end diff --git a/app/services/filter_service.rb b/app/services/filter_service.rb index 5cb589e1b..bbc949efa 100644 --- a/app/services/filter_service.rb +++ b/app/services/filter_service.rb @@ -3,12 +3,7 @@ require 'json' class FilterService ATTRIBUTE_MODEL = 'conversation_attribute'.freeze ATTRIBUTE_TYPES = { - date: 'date', - text: 'text', - number: 'numeric', - link: 'text', - list: 'text', - checkbox: 'boolean' + date: 'date', text: 'text', number: 'numeric', link: 'text', list: 'text', checkbox: 'boolean' }.with_indifferent_access def initialize(params, user) @@ -60,7 +55,7 @@ class FilterService end def case_insensitive_values(query_hash) - if query_hash['custom_attribute_type'].present? && query_hash['values'][0].is_a?(String) + if @custom_attribute_type.present? && query_hash['values'][0].is_a?(String) string_filter_values(query_hash) else query_hash['values'] @@ -125,11 +120,13 @@ class FilterService query_operator = query_hash[:query_operator] table_name = attribute_model == 'conversation_attribute' ? 'conversations' : 'contacts' - if attribute_data_type == 'text' - " LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " - else - " (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " - end + query = if attribute_data_type == 'text' + " LOWER(#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " + else + " (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} #{filter_operator_value} #{query_operator} " + end + + query + not_in_custom_attr_query(table_name, query_hash, attribute_data_type) end def custom_attribute(attribute_key, account, custom_attribute_type) @@ -140,6 +137,12 @@ class FilterService ).find_by(attribute_key: attribute_key) end + def not_in_custom_attr_query(table_name, query_hash, attribute_data_type) + return '' unless query_hash[:filter_operator] == 'not_equal_to' + + " OR (#{table_name}.custom_attributes ->> '#{@attribute_key}')::#{attribute_data_type} IS NULL " + end + def equals_to_filter_string(filter_operator, current_index) return "IN (:value_#{current_index})" if filter_operator == 'equal_to' diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 0d5259a35..5cce41f9b 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -38,5 +38,6 @@ json.muted conversation.muted? json.snoozed_until conversation.snoozed_until json.status conversation.status json.timestamp conversation.last_activity_at.to_i +json.first_reply_created_at conversation.first_reply_created_at.to_i json.unread_count conversation.unread_incoming_messages.count json.last_non_activity_message conversation.messages.non_activity_messages.first.try(:push_event_data) diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 414dc250a..0df3ca5a7 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -33,7 +33,7 @@ json.website_token resource.channel.try(:website_token) json.selected_feature_flags resource.channel.try(:selected_feature_flags) json.reply_time resource.channel.try(:reply_time) if resource.web_widget? - json.hmac_token resource.channel.try(:hmac_token) + json.hmac_token resource.channel.try(:hmac_token) if Current.account_user&.administrator? json.pre_chat_form_enabled resource.channel.try(:pre_chat_form_enabled) json.pre_chat_form_options resource.channel.try(:pre_chat_form_options) json.continuity_via_email resource.channel.try(:continuity_via_email) @@ -56,29 +56,33 @@ if resource.email? json.email resource.channel.try(:email) ## IMAP - json.imap_login resource.channel.try(:imap_login) - json.imap_password resource.channel.try(:imap_password) - json.imap_address resource.channel.try(:imap_address) - json.imap_port resource.channel.try(:imap_port) - json.imap_enabled resource.channel.try(:imap_enabled) - json.imap_enable_ssl resource.channel.try(:imap_enable_ssl) + if Current.account_user&.administrator? + json.imap_login resource.channel.try(:imap_login) + json.imap_password resource.channel.try(:imap_password) + json.imap_address resource.channel.try(:imap_address) + json.imap_port resource.channel.try(:imap_port) + json.imap_enabled resource.channel.try(:imap_enabled) + json.imap_enable_ssl resource.channel.try(:imap_enable_ssl) + end ## SMTP - json.smtp_login resource.channel.try(:smtp_login) - json.smtp_password resource.channel.try(:smtp_password) - json.smtp_address resource.channel.try(:smtp_address) - json.smtp_port resource.channel.try(:smtp_port) - json.smtp_enabled resource.channel.try(:smtp_enabled) - json.smtp_domain resource.channel.try(:smtp_domain) - json.smtp_enable_ssl_tls resource.channel.try(:smtp_enable_ssl_tls) - json.smtp_enable_starttls_auto resource.channel.try(:smtp_enable_starttls_auto) - json.smtp_openssl_verify_mode resource.channel.try(:smtp_openssl_verify_mode) - json.smtp_authentication resource.channel.try(:smtp_authentication) + if Current.account_user&.administrator? + json.smtp_login resource.channel.try(:smtp_login) + json.smtp_password resource.channel.try(:smtp_password) + json.smtp_address resource.channel.try(:smtp_address) + json.smtp_port resource.channel.try(:smtp_port) + json.smtp_enabled resource.channel.try(:smtp_enabled) + json.smtp_domain resource.channel.try(:smtp_domain) + json.smtp_enable_ssl_tls resource.channel.try(:smtp_enable_ssl_tls) + json.smtp_enable_starttls_auto resource.channel.try(:smtp_enable_starttls_auto) + json.smtp_openssl_verify_mode resource.channel.try(:smtp_openssl_verify_mode) + json.smtp_authentication resource.channel.try(:smtp_authentication) + end end ## API Channel Attributes if resource.api? - json.hmac_token resource.channel.try(:hmac_token) + json.hmac_token resource.channel.try(:hmac_token) if Current.account_user&.administrator? json.webhook_url resource.channel.try(:webhook_url) json.inbox_identifier resource.channel.try(:identifier) json.additional_attributes resource.channel.try(:additional_attributes) diff --git a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb index 690a70c53..d012d5f53 100644 --- a/spec/controllers/api/v1/accounts/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations_controller_spec.rb @@ -53,7 +53,7 @@ RSpec.describe 'Conversations API', type: :request do get "/api/v1/accounts/#{account.id}/conversations", headers: agent_1.create_new_auth_token, - params: { reply_status: 'unattended' }, + params: { conversation_type: 'unattended' }, as: :json expect(response).to have_http_status(:success) diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb index 83a84952c..577ed0365 100644 --- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb @@ -103,7 +103,62 @@ RSpec.describe 'Inboxes API', type: :request do as: :json expect(response).to have_http_status(:success) - expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(inbox.id) + data = JSON.parse(response.body, symbolize_names: true) + expect(data[:id]).to eq(inbox.id) + expect(data[:hmac_token]).to be_nil + end + + it 'returns empty imap details in inbox when agent' do + email_channel = create(:channel_email, account: account, imap_enabled: true, imap_login: 'test@test.com') + email_inbox = create(:inbox, channel: email_channel, account: account) + create(:inbox_member, user: agent, inbox: email_inbox) + + imap_connection = double + allow(Mail).to receive(:connection).and_return(imap_connection) + + get "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:imap_enabled]).to be_nil + expect(data[:imap_login]).to be_nil + end + + it 'returns imap details in inbox when admin' do + email_channel = create(:channel_email, account: account, imap_enabled: true, imap_login: 'test@test.com') + email_inbox = create(:inbox, channel: email_channel, account: account) + + imap_connection = double + allow(Mail).to receive(:connection).and_return(imap_connection) + + get "/api/v1/accounts/#{account.id}/inboxes/#{email_inbox.id}", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:imap_enabled]).to be_truthy + expect(data[:imap_login]).to eq('test@test.com') + end + + it 'fetch API inbox without hmac token when agent' do + api_channel = create(:channel_api, account: account) + api_inbox = create(:inbox, channel: api_channel, account: account) + create(:inbox_member, user: agent, inbox: api_inbox) + + get "/api/v1/accounts/#{account.id}/inboxes/#{api_inbox.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + + data = JSON.parse(response.body, symbolize_names: true) + + expect(data[:hmac_token]).to be_nil end end end diff --git a/spec/finders/conversation_finder_spec.rb b/spec/finders/conversation_finder_spec.rb index 906c0e3f2..15e4ff127 100644 --- a/spec/finders/conversation_finder_spec.rb +++ b/spec/finders/conversation_finder_spec.rb @@ -136,5 +136,15 @@ describe ::ConversationFinder do expect(result[:conversations].length).to be 25 end end + + context 'with unattended' do + let(:params) { { status: 'open', assignee_type: 'me', conversation_type: 'unattended' } } + + it 'returns unattended conversations' do + create_list(:conversation, 25, account: account, inbox: inbox, assignee: user_1) + result = conversation_finder.perform + expect(result[:conversations].length).to be 25 + end + end end end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 7c9c9ca87..06fe5f108 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -455,6 +455,7 @@ RSpec.describe Conversation, type: :model do channel: 'Channel::WebWidget', snoozed_until: conversation.snoozed_until, custom_attributes: conversation.custom_attributes, + first_reply_created_at: nil, contact_last_seen_at: conversation.contact_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i, unread_count: 0 diff --git a/spec/presenters/conversations/event_data_presenter_spec.rb b/spec/presenters/conversations/event_data_presenter_spec.rb index e358df4c9..3a194beb6 100644 --- a/spec/presenters/conversations/event_data_presenter_spec.rb +++ b/spec/presenters/conversations/event_data_presenter_spec.rb @@ -27,6 +27,7 @@ RSpec.describe Conversations::EventDataPresenter do timestamp: conversation.last_activity_at.to_i, snoozed_until: conversation.snoozed_until, custom_attributes: conversation.custom_attributes, + first_reply_created_at: nil, contact_last_seen_at: conversation.contact_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i, unread_count: 0 diff --git a/spec/services/conversations/filter_service_spec.rb b/spec/services/conversations/filter_service_spec.rb index a17351ec3..a3e6edc27 100644 --- a/spec/services/conversations/filter_service_spec.rb +++ b/spec/services/conversations/filter_service_spec.rb @@ -86,6 +86,17 @@ describe ::Conversations::FilterService do expect(result.length).to be conversations.count end + it 'filter conversations by additional_attributes with NOT_IN filter' do + payload = [{ attribute_key: 'conversation_type', filter_operator: 'not_equal_to', values: 'platinum', query_operator: nil, + custom_attribute_type: 'conversation_attribute' }.with_indifferent_access] + params[:payload] = payload + result = filter_service.new(params, user_1).perform + conversations = Conversation.where( + "custom_attributes ->> 'conversation_type' NOT IN (?) OR custom_attributes ->> 'conversation_type' IS NULL", ['platinum'] + ) + expect(result[:count][:all_count]).to be conversations.count + end + it 'filter conversations by tags' do unassigned_conversation.update_labels('support') params[:payload] = [