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,21 +10,36 @@ export const getSelectedChatConversation = ({
|
|||||||
}) =>
|
}) =>
|
||||||
allConversations.filter(conversation => conversation.id === selectedChatId);
|
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
|
// getters
|
||||||
const getters = {
|
const getters = {
|
||||||
getAllConversations: ({ allConversations, chatSortFilter }) => {
|
getAllConversations: ({ allConversations, chatSortFilter }) => {
|
||||||
const comparator = {
|
return allConversations.sort(sortComparator[chatSortFilter]);
|
||||||
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]);
|
|
||||||
},
|
},
|
||||||
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