mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	feat: Add a sort option for conversations waiting for a reply from an agent (#7364)
This commit is contained in:
		| @@ -6,7 +6,8 @@ class ConversationFinder | |||||||
|     latest: 'latest', |     latest: 'latest', | ||||||
|     sort_on_created_at: 'sort_on_created_at', |     sort_on_created_at: 'sort_on_created_at', | ||||||
|     last_user_message_at: 'last_user_message_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 |   }.with_indifferent_access | ||||||
|  |  | ||||||
|   # assumptions |   # assumptions | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ export default { | |||||||
|     LATEST: 'latest', |     LATEST: 'latest', | ||||||
|     CREATED_AT: 'sort_on_created_at', |     CREATED_AT: 'sort_on_created_at', | ||||||
|     PRIORITY: 'sort_on_priority', |     PRIORITY: 'sort_on_priority', | ||||||
|  |     WATIING_SINCE: 'waiting_since', | ||||||
|   }, |   }, | ||||||
|   ARTICLE_STATUS_TYPES: { |   ARTICLE_STATUS_TYPES: { | ||||||
|     DRAFT: 0, |     DRAFT: 0, | ||||||
|   | |||||||
| @@ -50,6 +50,9 @@ | |||||||
|       }, |       }, | ||||||
|       "sort_on_priority": { |       "sort_on_priority": { | ||||||
|         "TEXT": "Priority" |         "TEXT": "Priority" | ||||||
|  |       }, | ||||||
|  |       "sort_on_waiting_since": { | ||||||
|  |         "TEXT": "Pending Response" | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     "ATTACHMENTS": { |     "ATTACHMENTS": { | ||||||
|   | |||||||
| @@ -10,10 +10,7 @@ export const getSelectedChatConversation = ({ | |||||||
| }) => | }) => | ||||||
|   allConversations.filter(conversation => conversation.id === selectedChatId); |   allConversations.filter(conversation => conversation.id === selectedChatId); | ||||||
|  |  | ||||||
| // getters | const sortComparator = { | ||||||
| const getters = { |  | ||||||
|   getAllConversations: ({ allConversations, chatSortFilter }) => { |  | ||||||
|     const comparator = { |  | ||||||
|   latest: (a, b) => b.last_activity_at - a.last_activity_at, |   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_created_at: (a, b) => a.created_at - b.created_at, | ||||||
|   sort_on_priority: (a, b) => { |   sort_on_priority: (a, b) => { | ||||||
| @@ -22,9 +19,27 @@ const getters = { | |||||||
|       CONVERSATION_PRIORITY_ORDER[b.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; | ||||||
|  |   }, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|     return allConversations.sort(comparator[chatSortFilter]); | // getters | ||||||
|  | const getters = { | ||||||
|  |   getAllConversations: ({ allConversations, chatSortFilter }) => { | ||||||
|  |     return allConversations.sort(sortComparator[chatSortFilter]); | ||||||
|   }, |   }, | ||||||
|   getSelectedChat: ({ selectedChatId, allConversations }) => { |   getSelectedChat: ({ selectedChatId, allConversations }) => { | ||||||
|     const selectedChat = allConversations.find( |     const selectedChat = allConversations.find( | ||||||
|   | |||||||
| @@ -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', () => { |   describe('#getUnAssignedChats', () => { | ||||||
|     it('order returns only chats assigned to user', () => { |     it('order returns only chats assigned to user', () => { | ||||||
|   | |||||||
| @@ -29,5 +29,13 @@ module SortHandler | |||||||
|         ) |         ) | ||||||
|       ) |       ) | ||||||
|     end |     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 | ||||||
| end | end | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ | |||||||
| #  snoozed_until          :datetime | #  snoozed_until          :datetime | ||||||
| #  status                 :integer          default("open"), not null | #  status                 :integer          default("open"), not null | ||||||
| #  uuid                   :uuid             not null | #  uuid                   :uuid             not null | ||||||
|  | #  waiting_since          :datetime | ||||||
| #  created_at             :datetime         not null | #  created_at             :datetime         not null | ||||||
| #  updated_at             :datetime         not null | #  updated_at             :datetime         not null | ||||||
| #  account_id             :integer          not null | #  account_id             :integer          not null | ||||||
| @@ -45,6 +46,7 @@ | |||||||
| #  index_conversations_on_status_and_priority         (status,priority) | #  index_conversations_on_status_and_priority         (status,priority) | ||||||
| #  index_conversations_on_team_id                     (team_id) | #  index_conversations_on_team_id                     (team_id) | ||||||
| #  index_conversations_on_uuid                        (uuid) UNIQUE | #  index_conversations_on_uuid                        (uuid) UNIQUE | ||||||
|  | #  index_conversations_on_waiting_since               (waiting_since) | ||||||
| # | # | ||||||
|  |  | ||||||
| class Conversation < ApplicationRecord | class Conversation < ApplicationRecord | ||||||
| @@ -101,6 +103,7 @@ class Conversation < ApplicationRecord | |||||||
|  |  | ||||||
|   before_save :ensure_snooze_until_reset |   before_save :ensure_snooze_until_reset | ||||||
|   before_create :mark_conversation_pending_if_bot |   before_create :mark_conversation_pending_if_bot | ||||||
|  |   before_create :ensure_waiting_since | ||||||
|  |  | ||||||
|   after_update_commit :execute_after_update_commit_callbacks |   after_update_commit :execute_after_update_commit_callbacks | ||||||
|   after_create_commit :notify_conversation_creation |   after_create_commit :notify_conversation_creation | ||||||
| @@ -214,6 +217,10 @@ class Conversation < ApplicationRecord | |||||||
|     self.snoozed_until = nil unless snoozed? |     self.snoozed_until = nil unless snoozed? | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def ensure_waiting_since | ||||||
|  |     self.waiting_since = Time.now.utc | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def validate_additional_attributes |   def validate_additional_attributes | ||||||
|     self.additional_attributes = {} unless additional_attributes.is_a?(Hash) |     self.additional_attributes = {} unless additional_attributes.is_a?(Hash) | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -201,7 +201,14 @@ class Message < ApplicationRecord | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def valid_first_reply? |   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 |   end | ||||||
|  |  | ||||||
|   def save_story_info(story_info) |   def save_story_info(story_info) | ||||||
| @@ -238,39 +245,27 @@ class Message < ApplicationRecord | |||||||
|     send_reply |     send_reply | ||||||
|     execute_message_template_hooks |     execute_message_template_hooks | ||||||
|     update_contact_activity |     update_contact_activity | ||||||
|  |     update_waiting_since | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def update_contact_activity |   def update_contact_activity | ||||||
|     sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact) |     sender.update(last_activity_at: DateTime.now) if sender.is_a?(Contact) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def human_response? |   def update_waiting_since | ||||||
|     # given the checks are already in place, we need not query |     conversation.update(waiting_since: nil) if human_response? && !private && conversation.waiting_since.present? | ||||||
|     # 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? |  | ||||||
|  |  | ||||||
|     # if the sender is not a user, it's not a human response |     conversation.update(waiting_since: Time.now.utc) if incoming? && conversation.waiting_since.blank? | ||||||
|     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 |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def not_created_by_automation? |   def human_response? | ||||||
|     content_attributes['automation_rule_id'].blank? |     # 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 |   end | ||||||
|  |  | ||||||
|   def dispatch_create_events |   def dispatch_create_events | ||||||
|   | |||||||
| @@ -16,6 +16,7 @@ class Conversations::EventDataPresenter < SimpleDelegator | |||||||
|       unread_count: unread_incoming_messages.count, |       unread_count: unread_incoming_messages.count, | ||||||
|       first_reply_created_at: first_reply_created_at, |       first_reply_created_at: first_reply_created_at, | ||||||
|       priority: priority, |       priority: priority, | ||||||
|  |       waiting_since: waiting_since.to_i, | ||||||
|       **push_timestamps |       **push_timestamps | ||||||
|     } |     } | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -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_non_activity_message conversation.messages.non_activity_messages.first.try(:push_event_data) | ||||||
| json.last_activity_at conversation.last_activity_at.to_i | json.last_activity_at conversation.last_activity_at.to_i | ||||||
| json.priority conversation.priority | json.priority conversation.priority | ||||||
|  | json.waiting_since conversation.waiting_since.to_i.to_i | ||||||
|   | |||||||
| @@ -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 | ||||||
| @@ -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: 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 |   # These are extensions that must be enabled in order to support this database | ||||||
|   enable_extension "pg_stat_statements" |   enable_extension "pg_stat_statements" | ||||||
|   enable_extension "pg_trgm" |   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.datetime "first_reply_created_at", precision: nil | ||||||
|     t.integer "priority" |     t.integer "priority" | ||||||
|     t.bigint "sla_policy_id" |     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", "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", "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" |     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 ["status", "priority"], name: "index_conversations_on_status_and_priority" | ||||||
|     t.index ["team_id"], name: "index_conversations_on_team_id" |     t.index ["team_id"], name: "index_conversations_on_team_id" | ||||||
|     t.index ["uuid"], name: "index_conversations_on_uuid", unique: true |     t.index ["uuid"], name: "index_conversations_on_uuid", unique: true | ||||||
|  |     t.index ["waiting_since"], name: "index_conversations_on_waiting_since" | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   create_table "csat_survey_responses", force: :cascade do |t| |   create_table "csat_survey_responses", force: :cascade do |t| | ||||||
|   | |||||||
| @@ -32,6 +32,10 @@ RSpec.describe Conversation do | |||||||
|       expect(conversation.display_id).to eq(1) |       expect(conversation.display_id).to eq(1) | ||||||
|     end |     end | ||||||
|  |  | ||||||
|  |     it 'sets waiting since' do | ||||||
|  |       expect(conversation.waiting_since).not_to be_nil | ||||||
|  |     end | ||||||
|  |  | ||||||
|     it 'creates a UUID for every conversation automatically' do |     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 |       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) |       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, |         contact_last_seen_at: conversation.contact_last_seen_at.to_i, | ||||||
|         agent_last_seen_at: conversation.agent_last_seen_at.to_i, |         agent_last_seen_at: conversation.agent_last_seen_at.to_i, | ||||||
|         created_at: conversation.created_at.to_i, |         created_at: conversation.created_at.to_i, | ||||||
|  |         waiting_since: conversation.waiting_since.to_i, | ||||||
|         priority: nil, |         priority: nil, | ||||||
|         unread_count: 0 |         unread_count: 0 | ||||||
|       } |       } | ||||||
|   | |||||||
| @@ -138,6 +138,37 @@ RSpec.describe Message do | |||||||
|     end |     end | ||||||
|   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 |   context 'with webhook_data' do | ||||||
|     it 'contains the message attachment when attachment is present' do |     it 'contains the message attachment when attachment is present' do | ||||||
|       message = create(:message) |       message = create(:message) | ||||||
|   | |||||||
| @@ -31,6 +31,7 @@ RSpec.describe Conversations::EventDataPresenter do | |||||||
|         contact_last_seen_at: conversation.contact_last_seen_at.to_i, |         contact_last_seen_at: conversation.contact_last_seen_at.to_i, | ||||||
|         agent_last_seen_at: conversation.agent_last_seen_at.to_i, |         agent_last_seen_at: conversation.agent_last_seen_at.to_i, | ||||||
|         created_at: conversation.created_at.to_i, |         created_at: conversation.created_at.to_i, | ||||||
|  |         waiting_since: conversation.waiting_since.to_i, | ||||||
|         priority: nil, |         priority: nil, | ||||||
|         unread_count: 0 |         unread_count: 0 | ||||||
|       } |       } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Pranav Raj S
					Pranav Raj S