diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index a072b68b3..cbbd60caa 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -6,7 +6,8 @@ class ConversationFinder latest: 'latest', sort_on_created_at: 'sort_on_created_at', last_user_message_at: 'last_user_message_at', - sort_on_priority: 'sort_on_priority' + sort_on_priority: 'sort_on_priority', + sort_on_waiting_since: 'sort_on_waiting_since' }.with_indifferent_access # assumptions diff --git a/app/javascript/dashboard/constants/globals.js b/app/javascript/dashboard/constants/globals.js index 3f0ee46a8..1074810ad 100644 --- a/app/javascript/dashboard/constants/globals.js +++ b/app/javascript/dashboard/constants/globals.js @@ -16,6 +16,7 @@ export default { LATEST: 'latest', CREATED_AT: 'sort_on_created_at', PRIORITY: 'sort_on_priority', + WATIING_SINCE: 'waiting_since', }, ARTICLE_STATUS_TYPES: { DRAFT: 0, diff --git a/app/javascript/dashboard/i18n/locale/en/chatlist.json b/app/javascript/dashboard/i18n/locale/en/chatlist.json index 731748696..abfe8f8d2 100644 --- a/app/javascript/dashboard/i18n/locale/en/chatlist.json +++ b/app/javascript/dashboard/i18n/locale/en/chatlist.json @@ -50,6 +50,9 @@ }, "sort_on_priority": { "TEXT": "Priority" + }, + "sort_on_waiting_since": { + "TEXT": "Pending Response" } }, "ATTACHMENTS": { diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js index 0b1ca73d3..c55952e68 100644 --- a/app/javascript/dashboard/store/modules/conversations/getters.js +++ b/app/javascript/dashboard/store/modules/conversations/getters.js @@ -10,21 +10,36 @@ export const getSelectedChatConversation = ({ }) => allConversations.filter(conversation => conversation.id === selectedChatId); +const sortComparator = { + latest: (a, b) => b.last_activity_at - a.last_activity_at, + sort_on_created_at: (a, b) => a.created_at - b.created_at, + sort_on_priority: (a, b) => { + return ( + CONVERSATION_PRIORITY_ORDER[a.priority] - + CONVERSATION_PRIORITY_ORDER[b.priority] + ); + }, + sort_on_waiting_since: (a, b) => { + if (!a.waiting_since && !b.waiting_since) { + return a.created_at - b.created_at; + } + + if (!a.waiting_since) { + return 1; + } + + if (!b.waiting_since) { + return -1; + } + + return a.waiting_since - b.waiting_since; + }, +}; + // getters const getters = { getAllConversations: ({ allConversations, chatSortFilter }) => { - const comparator = { - latest: (a, b) => b.last_activity_at - a.last_activity_at, - sort_on_created_at: (a, b) => a.created_at - b.created_at, - sort_on_priority: (a, b) => { - return ( - CONVERSATION_PRIORITY_ORDER[a.priority] - - CONVERSATION_PRIORITY_ORDER[b.priority] - ); - }, - }; - - return allConversations.sort(comparator[chatSortFilter]); + return allConversations.sort(sortComparator[chatSortFilter]); }, getSelectedChat: ({ selectedChatId, allConversations }) => { const selectedChat = allConversations.find( diff --git a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js index e874b05ec..a4eaef4ba 100644 --- a/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/conversations/getters.spec.js @@ -190,6 +190,56 @@ describe('#getters', () => { }, ]); }); + it('order conversations based on waiting_since', () => { + const state = { + allConversations: [ + { + id: 3, + created_at: 1683645800, + waiting_since: 0, + }, + { + id: 4, + created_at: 1683645799, + waiting_since: 0, + }, + { + id: 1, + created_at: 1683645801, + waiting_since: 1683645802, + }, + { + id: 2, + created_at: 1683645803, + waiting_since: 1683645800, + }, + ], + chatSortFilter: 'sort_on_waiting_since', + }; + + expect(getters.getAllConversations(state)).toEqual([ + { + id: 2, + created_at: 1683645803, + waiting_since: 1683645800, + }, + { + id: 1, + created_at: 1683645801, + waiting_since: 1683645802, + }, + { + id: 4, + created_at: 1683645799, + waiting_since: 0, + }, + { + id: 3, + created_at: 1683645800, + waiting_since: 0, + }, + ]); + }); }); describe('#getUnAssignedChats', () => { it('order returns only chats assigned to user', () => { diff --git a/app/models/concerns/sort_handler.rb b/app/models/concerns/sort_handler.rb index 61950f32e..5c4dde7c3 100644 --- a/app/models/concerns/sort_handler.rb +++ b/app/models/concerns/sort_handler.rb @@ -29,5 +29,13 @@ module SortHandler ) ) end + + def self.sort_on_waiting_since + order( + Arel::Nodes::SqlLiteral.new( + sanitize_sql_for_order('CASE WHEN waiting_since IS NULL THEN now() ELSE waiting_since END ASC, created_at ASC') + ) + ) + end end end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 7a3bfcd68..efcca1c9b 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -15,6 +15,7 @@ # snoozed_until :datetime # status :integer default("open"), not null # uuid :uuid not null +# waiting_since :datetime # created_at :datetime not null # updated_at :datetime not null # account_id :integer not null @@ -45,6 +46,7 @@ # index_conversations_on_status_and_priority (status,priority) # index_conversations_on_team_id (team_id) # index_conversations_on_uuid (uuid) UNIQUE +# index_conversations_on_waiting_since (waiting_since) # class Conversation < ApplicationRecord @@ -101,6 +103,7 @@ class Conversation < ApplicationRecord before_save :ensure_snooze_until_reset before_create :mark_conversation_pending_if_bot + before_create :ensure_waiting_since after_update_commit :execute_after_update_commit_callbacks after_create_commit :notify_conversation_creation @@ -214,6 +217,10 @@ class Conversation < ApplicationRecord self.snoozed_until = nil unless snoozed? end + def ensure_waiting_since + self.waiting_since = Time.now.utc + end + def validate_additional_attributes self.additional_attributes = {} unless additional_attributes.is_a?(Hash) end diff --git a/app/models/message.rb b/app/models/message.rb index b4121523a..a16cb7b10 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -201,7 +201,14 @@ class Message < ApplicationRecord end def valid_first_reply? - outgoing? && human_response? && not_created_by_automation? && !private? + return false unless outgoing? && human_response? && !private? + return false if conversation.first_reply_created_at.present? + return false if conversation.messages.outgoing + .where.not(sender_type: 'AgentBot') + .where.not(private: true) + .where("(additional_attributes->'campaign_id') is null").count > 1 + + true end def save_story_info(story_info) @@ -238,39 +245,27 @@ class Message < ApplicationRecord send_reply execute_message_template_hooks update_contact_activity + update_waiting_since end def update_contact_activity sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact) end - def human_response? - # given the checks are already in place, we need not query - # the database again to check if the message is created by a human - # we can just see if the first_reply is recorded or not - # if it is record, we can just return false - return false if conversation.first_reply_created_at.present? + def update_waiting_since + conversation.update(waiting_since: nil) if human_response? && !private && conversation.waiting_since.present? - # if the sender is not a user, it's not a human response - return false unless sender.is_a?(User) - - # if automation rule id is present, it's not a human response - # if campaign id is present, it's not a human response - # this check already happens in `not_created_by_automation` but added here for the sake of brevity - # also the purity of this method is intact, and can be relied on this solely - return false if content_attributes['automation_rule_id'].present? || additional_attributes['campaign_id'].present? - - # adding this condition again to ensure if the first_reply_created_at is not present - return false if conversation.messages.outgoing - .where.not(sender_type: 'AgentBot') - .where.not(private: true) - .where("(additional_attributes->'campaign_id') is null").count > 1 - - true + conversation.update(waiting_since: Time.now.utc) if incoming? && conversation.waiting_since.blank? end - def not_created_by_automation? - content_attributes['automation_rule_id'].blank? + def human_response? + # if the sender is not a user, it's not a human response + # if automation rule id is present, it's not a human response + # if campaign id is present, it's not a human response + outgoing? && + content_attributes['automation_rule_id'].blank? && + additional_attributes['campaign_id'].blank? && + sender.is_a?(User) end def dispatch_create_events diff --git a/app/presenters/conversations/event_data_presenter.rb b/app/presenters/conversations/event_data_presenter.rb index 0eabad899..f739554b3 100644 --- a/app/presenters/conversations/event_data_presenter.rb +++ b/app/presenters/conversations/event_data_presenter.rb @@ -16,6 +16,7 @@ class Conversations::EventDataPresenter < SimpleDelegator unread_count: unread_incoming_messages.count, first_reply_created_at: first_reply_created_at, priority: priority, + waiting_since: waiting_since.to_i, **push_timestamps } end diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 78774ee95..a36366f9e 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -45,3 +45,4 @@ json.unread_count conversation.unread_incoming_messages.count json.last_non_activity_message conversation.messages.non_activity_messages.first.try(:push_event_data) json.last_activity_at conversation.last_activity_at.to_i json.priority conversation.priority +json.waiting_since conversation.waiting_since.to_i.to_i diff --git a/db/migrate/20230620212340_add_waiting_since_to_conversations.rb b/db/migrate/20230620212340_add_waiting_since_to_conversations.rb new file mode 100644 index 000000000..7b4d9fa69 --- /dev/null +++ b/db/migrate/20230620212340_add_waiting_since_to_conversations.rb @@ -0,0 +1,6 @@ +class AddWaitingSinceToConversations < ActiveRecord::Migration[7.0] + def change + add_column :conversations, :waiting_since, :datetime + add_index :conversations, :waiting_since + end +end diff --git a/db/schema.rb b/db/schema.rb index 8947aed66..595604d2b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_06_20_132319) do +ActiveRecord::Schema[7.0].define(version: 2023_06_20_212340) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -449,6 +449,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_06_20_132319) do t.datetime "first_reply_created_at", precision: nil t.integer "priority" t.bigint "sla_policy_id" + t.datetime "waiting_since" t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id" t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx" @@ -465,6 +466,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_06_20_132319) do t.index ["status", "priority"], name: "index_conversations_on_status_and_priority" t.index ["team_id"], name: "index_conversations_on_team_id" t.index ["uuid"], name: "index_conversations_on_uuid", unique: true + t.index ["waiting_since"], name: "index_conversations_on_waiting_since" end create_table "csat_survey_responses", force: :cascade do |t| diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index d5e5525a6..db50ebe50 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -32,6 +32,10 @@ RSpec.describe Conversation do expect(conversation.display_id).to eq(1) end + it 'sets waiting since' do + expect(conversation.waiting_since).not_to be_nil + end + it 'creates a UUID for every conversation automatically' do uuid_pattern = /[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}$/i expect(conversation.uuid).to match(uuid_pattern) @@ -523,6 +527,7 @@ RSpec.describe Conversation do contact_last_seen_at: conversation.contact_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i, created_at: conversation.created_at.to_i, + waiting_since: conversation.waiting_since.to_i, priority: nil, unread_count: 0 } diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 631e126a6..ff6964d34 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -138,6 +138,37 @@ RSpec.describe Message do end end + describe '#waiting since' do + let(:conversation) { create(:conversation) } + let(:agent) { create(:user, account: conversation.account) } + let(:message) { build(:message, conversation: conversation) } + + it 'resets the waiting_since if an agent sent a reply' do + message.message_type = :outgoing + message.sender = agent + message.save! + + expect(conversation.waiting_since).to be_nil + end + + it 'sets the waiting_since if there is an incoming message' do + conversation.update(waiting_since: nil) + message.message_type = :incoming + message.save! + + expect(conversation.waiting_since).not_to be_nil + end + + it 'does not overwrite the previous value if there are newer messages' do + old_waiting_since = conversation.waiting_since + message.message_type = :incoming + message.save! + conversation.reload + + expect(conversation.waiting_since).to eq old_waiting_since + end + end + context 'with webhook_data' do it 'contains the message attachment when attachment is present' do message = create(:message) diff --git a/spec/presenters/conversations/event_data_presenter_spec.rb b/spec/presenters/conversations/event_data_presenter_spec.rb index 15ada3e32..557d2b95c 100644 --- a/spec/presenters/conversations/event_data_presenter_spec.rb +++ b/spec/presenters/conversations/event_data_presenter_spec.rb @@ -31,6 +31,7 @@ RSpec.describe Conversations::EventDataPresenter do contact_last_seen_at: conversation.contact_last_seen_at.to_i, agent_last_seen_at: conversation.agent_last_seen_at.to_i, created_at: conversation.created_at.to_i, + waiting_since: conversation.waiting_since.to_i, priority: nil, unread_count: 0 }