Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e06a15a432 | ||
|
|
e5cbc174d5 | ||
|
|
fc9af7f315 | ||
|
|
cba8e83b91 | ||
|
|
6325bd667a | ||
|
|
92449e273c | ||
|
|
fb81a4fc89 |
@@ -1,6 +1,18 @@
|
|||||||
class ReportingEventListener < BaseListener
|
class ReportingEventListener < BaseListener
|
||||||
include ReportingEventHelper
|
include ReportingEventHelper
|
||||||
|
|
||||||
|
def conversation_created(event)
|
||||||
|
conversation = extract_conversation_and_account(event)[0]
|
||||||
|
|
||||||
|
ReportingEvent.create!(
|
||||||
|
name: 'conversation_created',
|
||||||
|
value: 0,
|
||||||
|
event_start_time: conversation.created_at,
|
||||||
|
event_end_time: conversation.created_at,
|
||||||
|
**base_event_attributes(conversation, from_state: nil)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def conversation_resolved(event)
|
def conversation_resolved(event)
|
||||||
conversation = extract_conversation_and_account(event)[0]
|
conversation = extract_conversation_and_account(event)[0]
|
||||||
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
|
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||||
@@ -8,14 +20,10 @@ class ReportingEventListener < BaseListener
|
|||||||
reporting_event = ReportingEvent.new(
|
reporting_event = ReportingEvent.new(
|
||||||
name: 'conversation_resolved',
|
name: 'conversation_resolved',
|
||||||
value: time_to_resolve,
|
value: time_to_resolve,
|
||||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
|
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
|
||||||
conversation.updated_at),
|
|
||||||
account_id: conversation.account_id,
|
|
||||||
inbox_id: conversation.inbox_id,
|
|
||||||
user_id: conversation.assignee_id,
|
|
||||||
conversation_id: conversation.id,
|
|
||||||
event_start_time: conversation.created_at,
|
event_start_time: conversation.created_at,
|
||||||
event_end_time: conversation.updated_at
|
event_end_time: conversation.updated_at,
|
||||||
|
**base_event_attributes(conversation, from_state: 'handling')
|
||||||
)
|
)
|
||||||
|
|
||||||
create_bot_resolved_event(conversation, reporting_event)
|
create_bot_resolved_event(conversation, reporting_event)
|
||||||
@@ -27,20 +35,14 @@ class ReportingEventListener < BaseListener
|
|||||||
conversation = message.conversation
|
conversation = message.conversation
|
||||||
first_response_time = message.created_at.to_i - last_non_human_activity(conversation).to_i
|
first_response_time = message.created_at.to_i - last_non_human_activity(conversation).to_i
|
||||||
|
|
||||||
reporting_event = ReportingEvent.new(
|
ReportingEvent.create!(
|
||||||
name: 'first_response',
|
name: 'first_response',
|
||||||
value: first_response_time,
|
value: first_response_time,
|
||||||
value_in_business_hours: business_hours(conversation.inbox, last_non_human_activity(conversation),
|
value_in_business_hours: business_hours(conversation.inbox, last_non_human_activity(conversation), message.created_at),
|
||||||
message.created_at),
|
|
||||||
account_id: conversation.account_id,
|
|
||||||
inbox_id: conversation.inbox_id,
|
|
||||||
user_id: message.sender_id,
|
|
||||||
conversation_id: conversation.id,
|
|
||||||
event_start_time: last_non_human_activity(conversation),
|
event_start_time: last_non_human_activity(conversation),
|
||||||
event_end_time: message.created_at
|
event_end_time: message.created_at,
|
||||||
|
**base_event_attributes(conversation, from_state: 'waiting', user_id: message.sender_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
reporting_event.save!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def reply_created(event)
|
def reply_created(event)
|
||||||
@@ -50,21 +52,16 @@ class ReportingEventListener < BaseListener
|
|||||||
|
|
||||||
return if waiting_since.blank?
|
return if waiting_since.blank?
|
||||||
|
|
||||||
# When waiting_since is nil, set reply_time to 0
|
|
||||||
reply_time = message.created_at.to_i - waiting_since.to_i
|
reply_time = message.created_at.to_i - waiting_since.to_i
|
||||||
|
|
||||||
reporting_event = ReportingEvent.new(
|
ReportingEvent.create!(
|
||||||
name: 'reply_time',
|
name: 'reply_time',
|
||||||
value: reply_time,
|
value: reply_time,
|
||||||
value_in_business_hours: business_hours(conversation.inbox, waiting_since, message.created_at),
|
value_in_business_hours: business_hours(conversation.inbox, waiting_since, message.created_at),
|
||||||
account_id: conversation.account_id,
|
|
||||||
inbox_id: conversation.inbox_id,
|
|
||||||
user_id: conversation.assignee_id,
|
|
||||||
conversation_id: conversation.id,
|
|
||||||
event_start_time: waiting_since,
|
event_start_time: waiting_since,
|
||||||
event_end_time: message.created_at
|
event_end_time: message.created_at,
|
||||||
|
**base_event_attributes(conversation, from_state: 'waiting')
|
||||||
)
|
)
|
||||||
reporting_event.save!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_bot_handoff(event)
|
def conversation_bot_handoff(event)
|
||||||
@@ -76,18 +73,14 @@ class ReportingEventListener < BaseListener
|
|||||||
|
|
||||||
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
|
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||||
|
|
||||||
reporting_event = ReportingEvent.new(
|
ReportingEvent.create!(
|
||||||
name: 'conversation_bot_handoff',
|
name: 'conversation_bot_handoff',
|
||||||
value: time_to_handoff,
|
value: time_to_handoff,
|
||||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
|
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
|
||||||
account_id: conversation.account_id,
|
|
||||||
inbox_id: conversation.inbox_id,
|
|
||||||
user_id: conversation.assignee_id,
|
|
||||||
conversation_id: conversation.id,
|
|
||||||
event_start_time: conversation.created_at,
|
event_start_time: conversation.created_at,
|
||||||
event_end_time: conversation.updated_at
|
event_end_time: conversation.updated_at,
|
||||||
|
**base_event_attributes(conversation, from_state: 'bot_handling')
|
||||||
)
|
)
|
||||||
reporting_event.save!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def conversation_opened(event)
|
def conversation_opened(event)
|
||||||
@@ -99,36 +92,47 @@ class ReportingEventListener < BaseListener
|
|||||||
name: 'conversation_resolved'
|
name: 'conversation_resolved'
|
||||||
).order(event_end_time: :desc).first
|
).order(event_end_time: :desc).first
|
||||||
|
|
||||||
# For first-time openings, value is 0
|
# For first-time openings, value is 0, from_state is nil
|
||||||
# For reopenings, calculate time since resolution
|
# For reopenings, calculate time since resolution, from_state is 'resolved'
|
||||||
if last_resolved_event
|
if last_resolved_event
|
||||||
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
|
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
|
||||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
|
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
|
||||||
start_time = last_resolved_event.event_end_time
|
start_time = last_resolved_event.event_end_time
|
||||||
|
from_state = 'resolved'
|
||||||
else
|
else
|
||||||
time_since_resolved = 0
|
time_since_resolved = 0
|
||||||
business_hours_value = 0
|
business_hours_value = 0
|
||||||
start_time = conversation.created_at
|
start_time = conversation.created_at
|
||||||
|
from_state = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, from_state)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
def base_event_attributes(conversation, from_state:, user_id: nil)
|
||||||
reporting_event = ReportingEvent.new(
|
{
|
||||||
|
account_id: conversation.account_id,
|
||||||
|
inbox_id: conversation.inbox_id,
|
||||||
|
user_id: user_id || conversation.assignee_id,
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
conversation_created_at: conversation.created_at,
|
||||||
|
team_id: conversation.team_id,
|
||||||
|
channel_type: conversation.inbox.channel_type,
|
||||||
|
from_state: from_state
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, from_state)
|
||||||
|
ReportingEvent.create!(
|
||||||
name: 'conversation_opened',
|
name: 'conversation_opened',
|
||||||
value: time_since_resolved,
|
value: time_since_resolved,
|
||||||
value_in_business_hours: business_hours_value,
|
value_in_business_hours: business_hours_value,
|
||||||
account_id: conversation.account_id,
|
|
||||||
inbox_id: conversation.inbox_id,
|
|
||||||
user_id: conversation.assignee_id,
|
|
||||||
conversation_id: conversation.id,
|
|
||||||
event_start_time: start_time,
|
event_start_time: start_time,
|
||||||
event_end_time: conversation.updated_at
|
event_end_time: conversation.updated_at,
|
||||||
|
**base_event_attributes(conversation, from_state: from_state)
|
||||||
)
|
)
|
||||||
reporting_event.save!
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_bot_resolved_event(conversation, reporting_event)
|
def create_bot_resolved_event(conversation, reporting_event)
|
||||||
@@ -138,6 +142,7 @@ class ReportingEventListener < BaseListener
|
|||||||
|
|
||||||
bot_resolved_event = reporting_event.dup
|
bot_resolved_event = reporting_event.dup
|
||||||
bot_resolved_event.name = 'conversation_bot_resolved'
|
bot_resolved_event.name = 'conversation_bot_resolved'
|
||||||
|
bot_resolved_event.from_state = 'bot_handling'
|
||||||
bot_resolved_event.save!
|
bot_resolved_event.save!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
# Table name: reporting_events
|
# Table name: reporting_events
|
||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
|
# channel_type :string
|
||||||
|
# conversation_created_at :datetime
|
||||||
# event_end_time :datetime
|
# event_end_time :datetime
|
||||||
# event_start_time :datetime
|
# event_start_time :datetime
|
||||||
|
# from_state :string
|
||||||
# name :string
|
# name :string
|
||||||
# value :float
|
# value :float
|
||||||
# value_in_business_hours :float
|
# value_in_business_hours :float
|
||||||
@@ -13,17 +16,22 @@
|
|||||||
# account_id :integer
|
# account_id :integer
|
||||||
# conversation_id :integer
|
# conversation_id :integer
|
||||||
# inbox_id :integer
|
# inbox_id :integer
|
||||||
|
# team_id :bigint
|
||||||
# user_id :integer
|
# user_id :integer
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_reporting_events_on_account_id (account_id)
|
# idx_reporting_events_on_account_channel_name_created (account_id,channel_type,name,created_at)
|
||||||
# index_reporting_events_on_conversation_id (conversation_id)
|
# idx_reporting_events_on_account_conv_created_at_name (account_id,conversation_created_at,name)
|
||||||
# index_reporting_events_on_created_at (created_at)
|
# idx_reporting_events_on_account_from_state_name_created (account_id,from_state,name,created_at)
|
||||||
# index_reporting_events_on_inbox_id (inbox_id)
|
# idx_reporting_events_on_account_team_name_created (account_id,team_id,name,created_at)
|
||||||
# index_reporting_events_on_name (name)
|
# index_reporting_events_on_account_id (account_id)
|
||||||
# index_reporting_events_on_user_id (user_id)
|
# index_reporting_events_on_conversation_id (conversation_id)
|
||||||
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
# index_reporting_events_on_created_at (created_at)
|
||||||
|
# index_reporting_events_on_inbox_id (inbox_id)
|
||||||
|
# index_reporting_events_on_name (name)
|
||||||
|
# index_reporting_events_on_user_id (user_id)
|
||||||
|
# reporting_events__account_id__name__created_at (account_id,name,created_at)
|
||||||
#
|
#
|
||||||
|
|
||||||
class ReportingEvent < ApplicationRecord
|
class ReportingEvent < ApplicationRecord
|
||||||
@@ -35,6 +43,7 @@ class ReportingEvent < ApplicationRecord
|
|||||||
belongs_to :user, optional: true
|
belongs_to :user, optional: true
|
||||||
belongs_to :inbox, optional: true
|
belongs_to :inbox, optional: true
|
||||||
belongs_to :conversation, optional: true
|
belongs_to :conversation, optional: true
|
||||||
|
belongs_to :team, optional: true
|
||||||
|
|
||||||
# Scopes for filtering
|
# Scopes for filtering
|
||||||
scope :filter_by_date_range, lambda { |range|
|
scope :filter_by_date_range, lambda { |range|
|
||||||
@@ -52,4 +61,12 @@ class ReportingEvent < ApplicationRecord
|
|||||||
scope :filter_by_name, lambda { |name|
|
scope :filter_by_name, lambda { |name|
|
||||||
where(name: name) if name.present?
|
where(name: name) if name.present?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scope :filter_by_team_id, lambda { |team_id|
|
||||||
|
where(team_id: team_id) if team_id.present?
|
||||||
|
}
|
||||||
|
|
||||||
|
scope :filter_by_channel_type, lambda { |channel_type|
|
||||||
|
where(channel_type: channel_type) if channel_type.present?
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
class AddFieldsToReportingEvents < ActiveRecord::Migration[7.1]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
add_column :reporting_events, :conversation_created_at, :datetime
|
||||||
|
add_column :reporting_events, :from_state, :string
|
||||||
|
add_column :reporting_events, :team_id, :bigint
|
||||||
|
add_column :reporting_events, :channel_type, :string
|
||||||
|
|
||||||
|
add_index :reporting_events, [:account_id, :conversation_created_at, :name],
|
||||||
|
algorithm: :concurrently, name: 'idx_reporting_events_on_account_conv_created_at_name'
|
||||||
|
add_index :reporting_events, [:account_id, :from_state, :name, :created_at],
|
||||||
|
algorithm: :concurrently, name: 'idx_reporting_events_on_account_from_state_name_created'
|
||||||
|
add_index :reporting_events, [:account_id, :team_id, :name, :created_at],
|
||||||
|
algorithm: :concurrently, name: 'idx_reporting_events_on_account_team_name_created'
|
||||||
|
add_index :reporting_events, [:account_id, :channel_type, :name, :created_at],
|
||||||
|
algorithm: :concurrently, name: 'idx_reporting_events_on_account_channel_name_created'
|
||||||
|
end
|
||||||
|
end
|
||||||
+9
-1
@@ -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.1].define(version: 2026_01_20_121402) do
|
ActiveRecord::Schema[7.1].define(version: 2026_01_21_121101) do
|
||||||
# These extensions should be enabled to support this database
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@@ -1114,7 +1114,15 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do
|
|||||||
t.float "value_in_business_hours"
|
t.float "value_in_business_hours"
|
||||||
t.datetime "event_start_time", precision: nil
|
t.datetime "event_start_time", precision: nil
|
||||||
t.datetime "event_end_time", precision: nil
|
t.datetime "event_end_time", precision: nil
|
||||||
|
t.datetime "conversation_created_at"
|
||||||
|
t.string "from_state"
|
||||||
|
t.bigint "team_id"
|
||||||
|
t.string "channel_type"
|
||||||
|
t.index ["account_id", "channel_type", "name", "created_at"], name: "idx_reporting_events_on_account_channel_name_created"
|
||||||
|
t.index ["account_id", "conversation_created_at", "name"], name: "idx_reporting_events_on_account_conv_created_at_name"
|
||||||
|
t.index ["account_id", "from_state", "name", "created_at"], name: "idx_reporting_events_on_account_from_state_name_created"
|
||||||
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
|
t.index ["account_id", "name", "created_at"], name: "reporting_events__account_id__name__created_at"
|
||||||
|
t.index ["account_id", "team_id", "name", "created_at"], name: "idx_reporting_events_on_account_team_name_created"
|
||||||
t.index ["account_id"], name: "index_reporting_events_on_account_id"
|
t.index ["account_id"], name: "index_reporting_events_on_account_id"
|
||||||
t.index ["conversation_id"], name: "index_reporting_events_on_conversation_id"
|
t.index ["conversation_id"], name: "index_reporting_events_on_conversation_id"
|
||||||
t.index ["created_at"], name: "index_reporting_events_on_created_at"
|
t.index ["created_at"], name: "index_reporting_events_on_created_at"
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Usage: bundle exec rails runner lib/scripts/sankey_data_generator.rb [account_id] [days]
|
||||||
|
# Example: bundle exec rails runner lib/scripts/sankey_data_generator.rb 2 30
|
||||||
|
#
|
||||||
|
# Generates Sankey diagram data from reporting_events for conversation flow visualization.
|
||||||
|
# The flow shows: Conversations → Bot/Direct → Resolution paths
|
||||||
|
|
||||||
|
class SankeyDataGenerator
|
||||||
|
def initialize(account_id:, days: 90)
|
||||||
|
@account_id = account_id
|
||||||
|
@days = days
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate
|
||||||
|
{
|
||||||
|
nodes: build_nodes,
|
||||||
|
links: build_links
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_json(*_args)
|
||||||
|
JSON.pretty_generate(generate)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def base_scope
|
||||||
|
ReportingEvent.where(account_id: @account_id).where('conversation_created_at >= ?', @days.days.ago)
|
||||||
|
end
|
||||||
|
|
||||||
|
def conversation_created
|
||||||
|
@conversation_created ||= base_scope.where(name: 'conversation_created').distinct.count(:conversation_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def bot_handoff
|
||||||
|
@bot_handoff ||= base_scope.where(name: 'conversation_bot_handoff').distinct.count(:conversation_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def bot_resolved
|
||||||
|
@bot_resolved ||= begin
|
||||||
|
# Exclude conversations that were later handed off (they go through handoff flow instead)
|
||||||
|
bot_resolved_ids = base_scope.where(name: 'conversation_bot_resolved').distinct.pluck(:conversation_id)
|
||||||
|
handoff_ids = base_scope.where(name: 'conversation_bot_handoff').distinct.pluck(:conversation_id)
|
||||||
|
(bot_resolved_ids - handoff_ids).count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def agent_resolved
|
||||||
|
@agent_resolved ||= base_scope.where(name: 'conversation_resolved').distinct.count(:conversation_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reopened_conversation_ids
|
||||||
|
@reopened_conversation_ids ||= base_scope
|
||||||
|
.where(name: 'conversation_opened', from_state: 'resolved')
|
||||||
|
.distinct
|
||||||
|
.pluck(:conversation_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reopened
|
||||||
|
@reopened ||= reopened_conversation_ids.count
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reopened from bot_resolved (exclusive - not handed off after)
|
||||||
|
def reopened_from_bot
|
||||||
|
@reopened_from_bot ||= begin
|
||||||
|
bot_resolved_ids = base_scope.where(name: 'conversation_bot_resolved').pluck(:conversation_id)
|
||||||
|
handoff_ids = base_scope.where(name: 'conversation_bot_handoff').pluck(:conversation_id)
|
||||||
|
# Only count if bot_resolved but NOT handed off (stayed in bot flow)
|
||||||
|
bot_only_ids = bot_resolved_ids - handoff_ids
|
||||||
|
(reopened_conversation_ids & bot_only_ids).count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reopened from handoff_resolved (went through handoff path)
|
||||||
|
def reopened_from_handoff
|
||||||
|
@reopened_from_handoff ||= begin
|
||||||
|
handoff_ids = base_scope.where(name: 'conversation_bot_handoff').pluck(:conversation_id)
|
||||||
|
resolved_after_handoff_ids = base_scope
|
||||||
|
.where(name: 'conversation_resolved', conversation_id: handoff_ids)
|
||||||
|
.pluck(:conversation_id)
|
||||||
|
(reopened_conversation_ids & resolved_after_handoff_ids).count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reopened from direct agent resolution (no bot involvement)
|
||||||
|
def reopened_from_direct
|
||||||
|
@reopened_from_direct ||= [reopened - reopened_from_bot - reopened_from_handoff, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
|
# Conversations assigned to bot (created in bot-enabled inboxes)
|
||||||
|
def bot_assigned
|
||||||
|
@bot_assigned ||= begin
|
||||||
|
return 0 if bot_inbox_ids.empty?
|
||||||
|
|
||||||
|
base_scope.where(name: 'conversation_created', inbox_id: bot_inbox_ids).distinct.count(:conversation_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Conversations that went directly to agent (no bot involvement)
|
||||||
|
def direct_to_agent
|
||||||
|
@direct_to_agent ||= [conversation_created - bot_assigned, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
|
# Count conversations that have both bot_handoff AND conversation_resolved events
|
||||||
|
def resolved_after_handoff
|
||||||
|
@resolved_after_handoff ||= begin
|
||||||
|
handoff_conversation_ids = base_scope
|
||||||
|
.where(name: 'conversation_bot_handoff')
|
||||||
|
.distinct
|
||||||
|
.pluck(:conversation_id)
|
||||||
|
|
||||||
|
return 0 if handoff_conversation_ids.empty?
|
||||||
|
|
||||||
|
base_scope
|
||||||
|
.where(name: 'conversation_resolved')
|
||||||
|
.where(conversation_id: handoff_conversation_ids)
|
||||||
|
.distinct
|
||||||
|
.count(:conversation_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Agent resolutions from direct conversations (no bot involvement)
|
||||||
|
def resolved_direct_by_agent
|
||||||
|
@resolved_direct_by_agent ||= begin
|
||||||
|
# Get conversation IDs that went direct to agent (not in bot inboxes)
|
||||||
|
direct_conversation_ids = base_scope
|
||||||
|
.where(name: 'conversation_created')
|
||||||
|
.where.not(inbox_id: bot_inbox_ids)
|
||||||
|
.distinct
|
||||||
|
.pluck(:conversation_id)
|
||||||
|
|
||||||
|
return 0 if direct_conversation_ids.empty?
|
||||||
|
|
||||||
|
base_scope
|
||||||
|
.where(name: 'conversation_resolved')
|
||||||
|
.where(conversation_id: direct_conversation_ids)
|
||||||
|
.distinct
|
||||||
|
.count(:conversation_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def bot_inbox_ids
|
||||||
|
@bot_inbox_ids ||= Inbox.where(account_id: @account_id).select(&:active_bot?).map(&:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unresolved at bot level (assigned to bot but not resolved or handed off yet)
|
||||||
|
def unresolved_at_bot
|
||||||
|
@unresolved_at_bot ||= [bot_assigned - bot_resolved - bot_handoff, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unresolved after handoff (handed off but not yet resolved)
|
||||||
|
def unresolved_after_handoff
|
||||||
|
@unresolved_after_handoff ||= [bot_handoff - resolved_after_handoff, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unresolved direct (went to agent but not yet resolved)
|
||||||
|
def unresolved_direct
|
||||||
|
@unresolved_direct ||= [direct_to_agent - resolved_direct_by_agent, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_nodes
|
||||||
|
[
|
||||||
|
{ id: 'conversations', label: 'Conversations', value: conversation_created, subgroups: %w[bot_flow agent_direct] },
|
||||||
|
|
||||||
|
{ id: 'bot_flow', label: 'Assigned to Bot', value: bot_assigned,
|
||||||
|
subgroups: %w[bot_resolved bot_handoff bot_unresolved] },
|
||||||
|
|
||||||
|
{ id: 'agent_direct', label: 'Direct to Agent', value: direct_to_agent,
|
||||||
|
subgroups: %w[agent_direct_resolved agent_direct_unresolved] },
|
||||||
|
|
||||||
|
{ id: 'bot_resolved', label: 'Resolved by Bot', value: bot_resolved,
|
||||||
|
subgroups: %w[bot_resolved_final bot_resolved_reopened] },
|
||||||
|
|
||||||
|
{ id: 'bot_resolved_final', label: 'Stayed Resolved', value: bot_resolved - reopened_from_bot, subgroups: [] },
|
||||||
|
{ id: 'bot_resolved_reopened', label: 'Reopened', value: reopened_from_bot, subgroups: [] },
|
||||||
|
|
||||||
|
{ id: 'bot_handoff', label: 'Handed off to Agent', value: bot_handoff,
|
||||||
|
subgroups: %w[handoff_resolved handoff_unresolved] },
|
||||||
|
|
||||||
|
{ id: 'bot_unresolved', label: 'Unresolved (Bot)', value: unresolved_at_bot, subgroups: [] },
|
||||||
|
|
||||||
|
{ id: 'handoff_resolved', label: 'Resolved by Agent', value: resolved_after_handoff,
|
||||||
|
subgroups: %w[handoff_resolved_final handoff_resolved_reopened] },
|
||||||
|
|
||||||
|
{ id: 'handoff_resolved_final', label: 'Stayed Resolved', value: resolved_after_handoff - reopened_from_handoff, subgroups: [] },
|
||||||
|
{ id: 'handoff_resolved_reopened', label: 'Reopened', value: reopened_from_handoff, subgroups: [] },
|
||||||
|
|
||||||
|
{ id: 'handoff_unresolved', label: 'Unresolved', value: unresolved_after_handoff, subgroups: [] },
|
||||||
|
|
||||||
|
{ id: 'agent_direct_resolved', label: 'Resolved by Agent', value: resolved_direct_by_agent,
|
||||||
|
subgroups: %w[agent_direct_resolved_final agent_direct_resolved_reopened] },
|
||||||
|
|
||||||
|
{ id: 'agent_direct_resolved_final', label: 'Stayed Resolved', value: resolved_direct_by_agent - reopened_from_direct, subgroups: [] },
|
||||||
|
{ id: 'agent_direct_resolved_reopened', label: 'Reopened', value: reopened_from_direct, subgroups: [] },
|
||||||
|
|
||||||
|
{ id: 'agent_direct_unresolved', label: 'Unresolved', value: unresolved_direct, subgroups: [] }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_links
|
||||||
|
[
|
||||||
|
{ source: 'root', target: 'conversations', value: conversation_created },
|
||||||
|
|
||||||
|
{ source: 'conversations', target: 'bot_flow', value: bot_assigned },
|
||||||
|
{ source: 'conversations', target: 'agent_direct', value: direct_to_agent },
|
||||||
|
|
||||||
|
{ source: 'bot_flow', target: 'bot_resolved', value: bot_resolved },
|
||||||
|
{ source: 'bot_flow', target: 'bot_handoff', value: bot_handoff },
|
||||||
|
{ source: 'bot_flow', target: 'bot_unresolved', value: unresolved_at_bot },
|
||||||
|
|
||||||
|
{ source: 'bot_resolved', target: 'bot_resolved_final', value: bot_resolved - reopened_from_bot },
|
||||||
|
{ source: 'bot_resolved', target: 'bot_resolved_reopened', value: reopened_from_bot },
|
||||||
|
|
||||||
|
{ source: 'bot_handoff', target: 'handoff_resolved', value: resolved_after_handoff },
|
||||||
|
{ source: 'bot_handoff', target: 'handoff_unresolved', value: unresolved_after_handoff },
|
||||||
|
|
||||||
|
{ source: 'handoff_resolved', target: 'handoff_resolved_final', value: resolved_after_handoff - reopened_from_handoff },
|
||||||
|
{ source: 'handoff_resolved', target: 'handoff_resolved_reopened', value: reopened_from_handoff },
|
||||||
|
|
||||||
|
{ source: 'agent_direct', target: 'agent_direct_resolved', value: resolved_direct_by_agent },
|
||||||
|
{ source: 'agent_direct', target: 'agent_direct_unresolved', value: unresolved_direct },
|
||||||
|
|
||||||
|
{ source: 'agent_direct_resolved', target: 'agent_direct_resolved_final', value: resolved_direct_by_agent - reopened_from_direct },
|
||||||
|
{ source: 'agent_direct_resolved', target: 'agent_direct_resolved_reopened', value: reopened_from_direct }
|
||||||
|
]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if __FILE__ == $PROGRAM_NAME
|
||||||
|
account_id = ARGV[0]&.to_i || 2
|
||||||
|
days = ARGV[1]&.to_i || 90
|
||||||
|
|
||||||
|
generator = SankeyDataGenerator.new(account_id: account_id, days: days)
|
||||||
|
puts generator.to_json
|
||||||
|
end
|
||||||
@@ -13,6 +13,7 @@ class Seeders::Reports::ConversationCreator
|
|||||||
@teams = resources[:teams]
|
@teams = resources[:teams]
|
||||||
@labels = resources[:labels]
|
@labels = resources[:labels]
|
||||||
@agents = resources[:agents]
|
@agents = resources[:agents]
|
||||||
|
@captain_assistant = resources[:captain_assistant]
|
||||||
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
|
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -28,7 +29,13 @@ class Seeders::Reports::ConversationCreator
|
|||||||
conversation.save!
|
conversation.save!
|
||||||
|
|
||||||
add_labels_to_conversation(conversation)
|
add_labels_to_conversation(conversation)
|
||||||
create_messages_for_conversation(conversation)
|
|
||||||
|
# Check if this is a bot inbox or human-only inbox
|
||||||
|
if inbox_has_captain?(conversation.inbox)
|
||||||
|
create_bot_conversation_flow(conversation, created_at)
|
||||||
|
else
|
||||||
|
create_human_conversation_flow(conversation)
|
||||||
|
end
|
||||||
|
|
||||||
# Determine if should resolve but don't update yet
|
# Determine if should resolve but don't update yet
|
||||||
should_resolve = rand > 0.3
|
should_resolve = rand > 0.3
|
||||||
@@ -100,15 +107,80 @@ class Seeders::Reports::ConversationCreator
|
|||||||
conversation.update_labels(labels_to_add.map(&:title))
|
conversation.update_labels(labels_to_add.map(&:title))
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_messages_for_conversation(conversation)
|
def create_messages_for_conversation(conversation, include_bot: false)
|
||||||
message_creator = Seeders::Reports::MessageCreator.new(
|
message_creator = Seeders::Reports::MessageCreator.new(
|
||||||
account: @account,
|
account: @account,
|
||||||
agents: @agents,
|
agents: @agents,
|
||||||
conversation: conversation
|
conversation: conversation,
|
||||||
|
captain_assistant: include_bot ? @captain_assistant : nil
|
||||||
)
|
)
|
||||||
message_creator.create_messages
|
message_creator.create_messages
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def inbox_has_captain?(inbox)
|
||||||
|
return false unless @captain_assistant && inbox.respond_to?(:captain_assistant)
|
||||||
|
|
||||||
|
inbox.captain_assistant.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_bot_conversation_flow(conversation, _created_at)
|
||||||
|
message_creator = Seeders::Reports::MessageCreator.new(
|
||||||
|
account: @account,
|
||||||
|
agents: @agents,
|
||||||
|
conversation: conversation,
|
||||||
|
captain_assistant: @captain_assistant
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create initial customer message
|
||||||
|
message_creator.create_incoming_message
|
||||||
|
|
||||||
|
# Add delay for bot response
|
||||||
|
travel(rand((5.seconds)..(30.seconds)))
|
||||||
|
|
||||||
|
# Create bot response
|
||||||
|
message_creator.create_bot_message
|
||||||
|
|
||||||
|
# Randomly decide outcome: 30% bot resolves, 50% handoff to human, 20% abandoned
|
||||||
|
outcome = rand(100)
|
||||||
|
|
||||||
|
if outcome < 30
|
||||||
|
# Bot resolves - no more messages needed, will be resolved later
|
||||||
|
nil
|
||||||
|
elsif outcome < 80
|
||||||
|
# Bot hands off to human - use travel to move forward from current time
|
||||||
|
travel(rand((1.minute)..(10.minutes)))
|
||||||
|
trigger_bot_handoff_event(conversation)
|
||||||
|
# Continue with human agent messages
|
||||||
|
create_human_messages_after_handoff(message_creator)
|
||||||
|
end
|
||||||
|
# else: 20% abandoned - no further action
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_human_conversation_flow(conversation)
|
||||||
|
create_messages_for_conversation(conversation)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_human_messages_after_handoff(message_creator)
|
||||||
|
# Create a few more human agent messages after handoff
|
||||||
|
message_count = rand(2..4)
|
||||||
|
message_count.times do |i|
|
||||||
|
travel(rand((1.minute)..(30.minutes)))
|
||||||
|
if i.even?
|
||||||
|
message_creator.create_incoming_message
|
||||||
|
else
|
||||||
|
message_creator.create_human_outgoing_message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def trigger_bot_handoff_event(conversation)
|
||||||
|
event_data = { conversation: conversation }
|
||||||
|
|
||||||
|
ReportingEventListener.instance.conversation_bot_handoff(
|
||||||
|
Events::Base.new('conversation_bot_handoff', Time.current, event_data)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def trigger_conversation_resolved_event(conversation)
|
def trigger_conversation_resolved_event(conversation)
|
||||||
event_data = { conversation: conversation }
|
event_data = { conversation: conversation }
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ class Seeders::Reports::MessageCreator
|
|||||||
|
|
||||||
MESSAGES_PER_CONVERSATION = 5
|
MESSAGES_PER_CONVERSATION = 5
|
||||||
|
|
||||||
def initialize(account:, agents:, conversation:)
|
def initialize(account:, agents:, conversation:, captain_assistant: nil)
|
||||||
@account = account
|
@account = account
|
||||||
@agents = agents
|
@agents = agents
|
||||||
@conversation = conversation
|
@conversation = conversation
|
||||||
|
@captain_assistant = captain_assistant
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_messages
|
def create_messages
|
||||||
@@ -37,6 +38,44 @@ class Seeders::Reports::MessageCreator
|
|||||||
false # No longer first reply after any agent message
|
false # No longer first reply after any agent message
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def create_incoming_message
|
||||||
|
@conversation.messages.create!(
|
||||||
|
account: @account,
|
||||||
|
inbox: @conversation.inbox,
|
||||||
|
message_type: :incoming,
|
||||||
|
content: generate_customer_message_content,
|
||||||
|
sender: @conversation.contact
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_bot_message
|
||||||
|
return unless @captain_assistant
|
||||||
|
|
||||||
|
@conversation.messages.create!(
|
||||||
|
account: @account,
|
||||||
|
inbox: @conversation.inbox,
|
||||||
|
message_type: :outgoing,
|
||||||
|
content: generate_bot_response_content,
|
||||||
|
sender: @captain_assistant
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_human_outgoing_message
|
||||||
|
sender = @conversation.assignee || @agents.sample
|
||||||
|
|
||||||
|
message = @conversation.messages.create!(
|
||||||
|
account: @account,
|
||||||
|
inbox: @conversation.inbox,
|
||||||
|
message_type: :outgoing,
|
||||||
|
content: generate_message_content,
|
||||||
|
sender: sender
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigger reply events for human messages after handoff
|
||||||
|
trigger_reply_event(message)
|
||||||
|
message
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def add_realistic_delay(_message_index, is_incoming)
|
def add_realistic_delay(_message_index, is_incoming)
|
||||||
@@ -64,16 +103,6 @@ class Seeders::Reports::MessageCreator
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_incoming_message
|
|
||||||
@conversation.messages.create!(
|
|
||||||
account: @account,
|
|
||||||
inbox: @conversation.inbox,
|
|
||||||
message_type: :incoming,
|
|
||||||
content: generate_message_content,
|
|
||||||
sender: @conversation.contact
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_outgoing_message
|
def create_outgoing_message
|
||||||
sender = @conversation.assignee || @agents.sample
|
sender = @conversation.assignee || @agents.sample
|
||||||
|
|
||||||
@@ -90,6 +119,39 @@ class Seeders::Reports::MessageCreator
|
|||||||
Faker::Lorem.paragraph(sentence_count: rand(1..5))
|
Faker::Lorem.paragraph(sentence_count: rand(1..5))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def generate_customer_message_content
|
||||||
|
customer_messages = [
|
||||||
|
'Hi, I need help with my order.',
|
||||||
|
'Can you assist me with a billing issue?',
|
||||||
|
'I have a question about my account.',
|
||||||
|
"I'm having trouble logging in.",
|
||||||
|
'How do I change my password?',
|
||||||
|
'When will my order arrive?',
|
||||||
|
"I'd like to request a refund.",
|
||||||
|
'Can you help me update my shipping address?',
|
||||||
|
'I received the wrong item.',
|
||||||
|
'Is there a way to track my package?',
|
||||||
|
Faker::Lorem.paragraph(sentence_count: rand(1..3))
|
||||||
|
]
|
||||||
|
customer_messages.sample
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_bot_response_content
|
||||||
|
bot_responses = [
|
||||||
|
"Hello! I'm here to help. Let me look into that for you.",
|
||||||
|
'Thank you for reaching out. I can assist you with this.',
|
||||||
|
'I understand your concern. Let me check our records.',
|
||||||
|
'Sure, I can help with that! Could you provide more details?',
|
||||||
|
"I've found some information that might help.",
|
||||||
|
'Let me connect you with a specialist who can better assist you.',
|
||||||
|
'Based on your question, here are some options available to you.',
|
||||||
|
'I can see your account details. Let me review this for you.',
|
||||||
|
"Thank you for your patience. I'm processing your request now.",
|
||||||
|
'Is there anything else I can help you with today?'
|
||||||
|
]
|
||||||
|
bot_responses.sample
|
||||||
|
end
|
||||||
|
|
||||||
def handle_agent_reply_events(message, is_first_reply)
|
def handle_agent_reply_events(message, is_first_reply)
|
||||||
if is_first_reply
|
if is_first_reply
|
||||||
trigger_first_reply_event(message)
|
trigger_first_reply_event(message)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# to generate historical data with realistic timestamps.
|
# to generate historical data with realistic timestamps.
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data
|
# ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data
|
||||||
#
|
#
|
||||||
# This will create:
|
# This will create:
|
||||||
# - 1000 conversations with realistic message exchanges
|
# - 1000 conversations with realistic message exchanges
|
||||||
@@ -25,29 +25,36 @@ require 'faker'
|
|||||||
require_relative 'conversation_creator'
|
require_relative 'conversation_creator'
|
||||||
require_relative 'message_creator'
|
require_relative 'message_creator'
|
||||||
|
|
||||||
# rubocop:disable Rails/Output
|
# rubocop:disable Rails/Output, Metrics/ClassLength
|
||||||
class Seeders::Reports::ReportDataSeeder
|
class Seeders::Reports::ReportDataSeeder
|
||||||
include ActiveSupport::Testing::TimeHelpers
|
include ActiveSupport::Testing::TimeHelpers
|
||||||
|
|
||||||
TOTAL_CONVERSATIONS = 1000
|
|
||||||
TOTAL_CONTACTS = 100
|
|
||||||
TOTAL_AGENTS = 20
|
|
||||||
TOTAL_TEAMS = 5
|
|
||||||
TOTAL_LABELS = 30
|
|
||||||
TOTAL_INBOXES = 3
|
TOTAL_INBOXES = 3
|
||||||
MESSAGES_PER_CONVERSATION = 5
|
|
||||||
START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
|
|
||||||
END_DATE = Time.current
|
|
||||||
|
|
||||||
def initialize(account:)
|
def initialize(account:, config: {})
|
||||||
raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
|
raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
|
||||||
|
|
||||||
@account = account
|
@account = account
|
||||||
|
initialize_config(config)
|
||||||
@teams = []
|
@teams = []
|
||||||
@agents = []
|
@agents = []
|
||||||
@labels = []
|
@labels = []
|
||||||
@inboxes = []
|
@inboxes = []
|
||||||
@contacts = []
|
@contacts = []
|
||||||
|
@captain_assistant = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize_config(config)
|
||||||
|
defaults = { total_conversations: 1000, total_contacts: 100, total_agents: 20,
|
||||||
|
total_teams: 5, total_labels: 30, days_range: 90 }
|
||||||
|
config = defaults.merge(config)
|
||||||
|
|
||||||
|
@total_conversations = config[:total_conversations]
|
||||||
|
@total_contacts = config[:total_contacts]
|
||||||
|
@total_agents = config[:total_agents]
|
||||||
|
@total_teams = config[:total_teams]
|
||||||
|
@total_labels = config[:total_labels]
|
||||||
|
@days_range = config[:days_range]
|
||||||
end
|
end
|
||||||
|
|
||||||
def perform!
|
def perform!
|
||||||
@@ -56,6 +63,7 @@ class Seeders::Reports::ReportDataSeeder
|
|||||||
# Clear existing data
|
# Clear existing data
|
||||||
clear_existing_data
|
clear_existing_data
|
||||||
|
|
||||||
|
create_captain_assistant
|
||||||
create_teams
|
create_teams
|
||||||
create_agents
|
create_agents
|
||||||
create_labels
|
create_labels
|
||||||
@@ -78,26 +86,38 @@ class Seeders::Reports::ReportDataSeeder
|
|||||||
@account.contacts.destroy_all
|
@account.contacts.destroy_all
|
||||||
@account.agents.destroy_all
|
@account.agents.destroy_all
|
||||||
@account.reporting_events.destroy_all
|
@account.reporting_events.destroy_all
|
||||||
|
Captain::Assistant.where(account: @account).destroy_all if defined?(Captain::Assistant)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_captain_assistant
|
||||||
|
return unless defined?(Captain::Assistant)
|
||||||
|
|
||||||
|
@captain_assistant = Captain::Assistant.create!(
|
||||||
|
account: @account,
|
||||||
|
name: 'Support Bot',
|
||||||
|
description: 'AI assistant for customer support'
|
||||||
|
)
|
||||||
|
puts 'Created Captain assistant: Support Bot'
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_teams
|
def create_teams
|
||||||
TOTAL_TEAMS.times do |i|
|
@total_teams.times do |i|
|
||||||
team = @account.teams.create!(
|
team = @account.teams.create!(
|
||||||
name: "#{Faker::Company.industry} Team #{i + 1}"
|
name: "#{Faker::Company.industry} Team #{i + 1}"
|
||||||
)
|
)
|
||||||
@teams << team
|
@teams << team
|
||||||
print "\rCreating teams: #{i + 1}/#{TOTAL_TEAMS}"
|
print "\rCreating teams: #{i + 1}/#{@total_teams}"
|
||||||
end
|
end
|
||||||
|
|
||||||
print "\n"
|
print "\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_agents
|
def create_agents
|
||||||
TOTAL_AGENTS.times do |i|
|
@total_agents.times do |i|
|
||||||
user = create_single_agent(i)
|
user = create_single_agent(i)
|
||||||
assign_agent_to_teams(user)
|
assign_agent_to_teams(user)
|
||||||
@agents << user
|
@agents << user
|
||||||
print "\rCreating agents: #{i + 1}/#{TOTAL_AGENTS}"
|
print "\rCreating agents: #{i + 1}/#{@total_agents}"
|
||||||
end
|
end
|
||||||
|
|
||||||
print "\n"
|
print "\n"
|
||||||
@@ -134,22 +154,33 @@ class Seeders::Reports::ReportDataSeeder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_labels
|
def create_labels
|
||||||
TOTAL_LABELS.times do |i|
|
@total_labels.times do |i|
|
||||||
label = @account.labels.create!(
|
label = @account.labels.create!(
|
||||||
title: "Label-#{i + 1}-#{Faker::Lorem.word}",
|
title: "Label-#{i + 1}-#{Faker::Lorem.word}",
|
||||||
description: Faker::Company.catch_phrase,
|
description: Faker::Company.catch_phrase,
|
||||||
color: Faker::Color.hex_color
|
color: Faker::Color.hex_color
|
||||||
)
|
)
|
||||||
@labels << label
|
@labels << label
|
||||||
print "\rCreating labels: #{i + 1}/#{TOTAL_LABELS}"
|
print "\rCreating labels: #{i + 1}/#{@total_labels}"
|
||||||
end
|
end
|
||||||
|
|
||||||
print "\n"
|
print "\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_inboxes
|
def create_inboxes
|
||||||
TOTAL_INBOXES.times do |_i|
|
# Create inboxes: 2/3 with Captain (bot inboxes) and 1/3 without (human-only)
|
||||||
inbox = create_single_inbox
|
bot_inbox_count = (TOTAL_INBOXES * 2 / 3.0).ceil
|
||||||
|
human_inbox_count = TOTAL_INBOXES - bot_inbox_count
|
||||||
|
|
||||||
|
bot_inbox_count.times do |i|
|
||||||
|
inbox = create_web_inbox_with_captain("Support Bot Inbox #{i + 1}")
|
||||||
|
assign_agents_to_inbox(inbox)
|
||||||
|
@inboxes << inbox
|
||||||
|
print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}"
|
||||||
|
end
|
||||||
|
|
||||||
|
human_inbox_count.times do |i|
|
||||||
|
inbox = create_web_inbox("Human Support Inbox #{i + 1}")
|
||||||
assign_agents_to_inbox(inbox)
|
assign_agents_to_inbox(inbox)
|
||||||
@inboxes << inbox
|
@inboxes << inbox
|
||||||
print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}"
|
print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}"
|
||||||
@@ -158,14 +189,27 @@ class Seeders::Reports::ReportDataSeeder
|
|||||||
print "\n"
|
print "\n"
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_single_inbox
|
def create_web_inbox_with_captain(name)
|
||||||
|
inbox = create_web_inbox(name)
|
||||||
|
|
||||||
|
if @captain_assistant && defined?(CaptainInbox)
|
||||||
|
CaptainInbox.create!(
|
||||||
|
captain_assistant: @captain_assistant,
|
||||||
|
inbox: inbox
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
inbox
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_web_inbox(name)
|
||||||
channel = Channel::WebWidget.create!(
|
channel = Channel::WebWidget.create!(
|
||||||
website_url: "https://#{Faker::Internet.domain_name}",
|
website_url: "https://#{Faker::Internet.domain_name}",
|
||||||
account_id: @account.id
|
account_id: @account.id
|
||||||
)
|
)
|
||||||
|
|
||||||
@account.inboxes.create!(
|
@account.inboxes.create!(
|
||||||
name: "#{Faker::Company.name} Website",
|
name: name,
|
||||||
channel: channel
|
channel: channel
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@@ -176,9 +220,10 @@ class Seeders::Reports::ReportDataSeeder
|
|||||||
@agents
|
@agents
|
||||||
else
|
else
|
||||||
# Subsequent inboxes get random selection with some overlap
|
# Subsequent inboxes get random selection with some overlap
|
||||||
min_agents = [@agents.size / TOTAL_INBOXES, 10].max
|
min_agents = [(@agents.size / TOTAL_INBOXES), 1].max
|
||||||
max_agents = [(@agents.size * 0.8).to_i, 50].min
|
max_agents = [(@agents.size * 0.8).to_i, @agents.size].min
|
||||||
@agents.sample(rand(min_agents..max_agents))
|
sample_count = rand([min_agents, max_agents].min..[min_agents, max_agents].max)
|
||||||
|
@agents.sample(sample_count)
|
||||||
end
|
end
|
||||||
|
|
||||||
agents_to_assign.each do |agent|
|
agents_to_assign.each do |agent|
|
||||||
@@ -187,7 +232,7 @@ class Seeders::Reports::ReportDataSeeder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_contacts
|
def create_contacts
|
||||||
TOTAL_CONTACTS.times do |i|
|
@total_contacts.times do |i|
|
||||||
contact = @account.contacts.create!(
|
contact = @account.contacts.create!(
|
||||||
name: Faker::Name.name,
|
name: Faker::Name.name,
|
||||||
email: Faker::Internet.email,
|
email: Faker::Internet.email,
|
||||||
@@ -202,7 +247,7 @@ class Seeders::Reports::ReportDataSeeder
|
|||||||
)
|
)
|
||||||
@contacts << contact
|
@contacts << contact
|
||||||
|
|
||||||
print "\rCreating contacts: #{i + 1}/#{TOTAL_CONTACTS}"
|
print "\rCreating contacts: #{i + 1}/#{@total_contacts}"
|
||||||
end
|
end
|
||||||
|
|
||||||
print "\n"
|
print "\n"
|
||||||
@@ -216,19 +261,20 @@ class Seeders::Reports::ReportDataSeeder
|
|||||||
inboxes: @inboxes,
|
inboxes: @inboxes,
|
||||||
teams: @teams,
|
teams: @teams,
|
||||||
labels: @labels,
|
labels: @labels,
|
||||||
agents: @agents
|
agents: @agents,
|
||||||
|
captain_assistant: @captain_assistant
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
TOTAL_CONVERSATIONS.times do |i|
|
@total_conversations.times do |i|
|
||||||
created_at = Faker::Time.between(from: START_DATE, to: END_DATE)
|
created_at = Faker::Time.between(from: @days_range.days.ago, to: Time.current)
|
||||||
conversation_creator.create_conversation(created_at: created_at)
|
conversation_creator.create_conversation(created_at: created_at)
|
||||||
|
|
||||||
completion_percentage = ((i + 1).to_f / TOTAL_CONVERSATIONS * 100).round
|
completion_percentage = ((i + 1).to_f / @total_conversations * 100).round
|
||||||
print "\rCreating conversations: #{i + 1}/#{TOTAL_CONVERSATIONS} (#{completion_percentage}%)"
|
print "\rCreating conversations: #{i + 1}/#{@total_conversations} (#{completion_percentage}%)"
|
||||||
end
|
end
|
||||||
|
|
||||||
print "\n"
|
print "\n"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
# rubocop:enable Rails/Output
|
# rubocop:enable Rails/Output, Metrics/ClassLength
|
||||||
|
|||||||
@@ -1,24 +1,53 @@
|
|||||||
|
# rubocop:disable Rails/Output, Metrics/BlockLength
|
||||||
namespace :db do
|
namespace :db do
|
||||||
namespace :seed do
|
namespace :seed do
|
||||||
desc 'Seed test data for reports with conversations, contacts, agents, teams, and realistic reporting events'
|
desc 'Seed test data for reports with conversations, contacts, agents, teams, and realistic reporting events'
|
||||||
task reports_data: :environment do
|
task reports_data: :environment do
|
||||||
if ENV['ACCOUNT_ID'].blank?
|
abort 'This task can only be run in development environment.' unless Rails.env.development?
|
||||||
puts 'Please provide an ACCOUNT_ID environment variable'
|
abort 'ENABLE_ACCOUNT_SEEDING must be set to true.' unless ENV['ENABLE_ACCOUNT_SEEDING'] == 'true'
|
||||||
puts 'Usage: ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data'
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
ENV['ENABLE_ACCOUNT_SEEDING'] = 'true' if ENV['ENABLE_ACCOUNT_SEEDING'].blank?
|
account = prompt_for_account
|
||||||
|
config = prompt_for_config
|
||||||
|
abort 'Seeding cancelled.' unless confirm_seeding(account)
|
||||||
|
|
||||||
account_id = ENV.fetch('ACCOUNT_ID', nil)
|
seeder = Seeders::Reports::ReportDataSeeder.new(account: account, config: config)
|
||||||
account = Account.find(account_id)
|
|
||||||
|
|
||||||
puts "Starting reports data seeding for account: #{account.name} (ID: #{account.id})"
|
|
||||||
|
|
||||||
seeder = Seeders::Reports::ReportDataSeeder.new(account: account)
|
|
||||||
seeder.perform!
|
seeder.perform!
|
||||||
|
|
||||||
puts "Finished seeding reports data for account: #{account.name}"
|
puts "Finished seeding reports data for account: #{account.name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def prompt(message)
|
||||||
|
print "#{message}: "
|
||||||
|
$stdin.gets.chomp
|
||||||
|
end
|
||||||
|
|
||||||
|
def prompt_with_default(message, default)
|
||||||
|
input = prompt("#{message} (default: #{default})")
|
||||||
|
input.blank? ? default : input.to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def prompt_for_account
|
||||||
|
input = prompt('Enter Account ID')
|
||||||
|
account = Account.find_by(id: input)
|
||||||
|
abort "Account with ID #{input} not found." if account.nil?
|
||||||
|
account
|
||||||
|
end
|
||||||
|
|
||||||
|
def prompt_for_config
|
||||||
|
{
|
||||||
|
total_conversations: prompt_with_default('Total conversations', 1000),
|
||||||
|
total_contacts: prompt_with_default('Total contacts', 100),
|
||||||
|
total_agents: prompt_with_default('Total agents', 20),
|
||||||
|
total_teams: prompt_with_default('Total teams', 5),
|
||||||
|
total_labels: prompt_with_default('Total labels', 30),
|
||||||
|
days_range: prompt_with_default('Days of data to generate', 90)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def confirm_seeding(account)
|
||||||
|
puts "\nWARNING: This will DELETE all existing data for account '#{account.name}' (ID: #{account.id})"
|
||||||
|
prompt('Are you sure you want to proceed? (yes/no)').downcase == 'yes'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
# rubocop:enable Rails/Output, Metrics/BlockLength
|
||||||
|
|||||||
@@ -10,6 +10,57 @@ describe ReportingEventListener do
|
|||||||
account: account, inbox: inbox, conversation: conversation)
|
account: account, inbox: inbox, conversation: conversation)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe '#conversation_created' do
|
||||||
|
it 'creates conversation_created event' do
|
||||||
|
expect(account.reporting_events.where(name: 'conversation_created').count).to be 0
|
||||||
|
event = Events::Base.new('conversation.created', Time.zone.now, conversation: conversation)
|
||||||
|
listener.conversation_created(event)
|
||||||
|
expect(account.reporting_events.where(name: 'conversation_created').count).to be 1
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sets correct attributes for conversation_created event' do
|
||||||
|
event = Events::Base.new('conversation.created', Time.zone.now, conversation: conversation)
|
||||||
|
listener.conversation_created(event)
|
||||||
|
|
||||||
|
created_event = account.reporting_events.find_by(name: 'conversation_created')
|
||||||
|
expect(created_event.value).to eq 0
|
||||||
|
expect(created_event.account_id).to eq(account.id)
|
||||||
|
expect(created_event.inbox_id).to eq(inbox.id)
|
||||||
|
expect(created_event.conversation_id).to eq(conversation.id)
|
||||||
|
expect(created_event.user_id).to eq(user.id)
|
||||||
|
expect(created_event.event_start_time).to be_within(1.second).of(conversation.created_at)
|
||||||
|
expect(created_event.event_end_time).to be_within(1.second).of(conversation.created_at)
|
||||||
|
expect(created_event.from_state).to be_nil
|
||||||
|
expect(created_event.conversation_created_at).to be_within(1.second).of(conversation.created_at)
|
||||||
|
expect(created_event.channel_type).to eq(inbox.channel_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when conversation has no assignee' do
|
||||||
|
let(:unassigned_conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil) }
|
||||||
|
|
||||||
|
it 'creates conversation_created event with nil user_id' do
|
||||||
|
event = Events::Base.new('conversation.created', Time.zone.now, conversation: unassigned_conversation)
|
||||||
|
listener.conversation_created(event)
|
||||||
|
|
||||||
|
created_event = account.reporting_events.find_by(name: 'conversation_created', conversation_id: unassigned_conversation.id)
|
||||||
|
expect(created_event.user_id).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when conversation has team assigned' do
|
||||||
|
let(:team) { create(:team, account: account) }
|
||||||
|
let(:team_conversation) { create(:conversation, account: account, inbox: inbox, assignee: user, team: team) }
|
||||||
|
|
||||||
|
it 'sets team_id on the event' do
|
||||||
|
event = Events::Base.new('conversation.created', Time.zone.now, conversation: team_conversation)
|
||||||
|
listener.conversation_created(event)
|
||||||
|
|
||||||
|
created_event = account.reporting_events.find_by(name: 'conversation_created', conversation_id: team_conversation.id)
|
||||||
|
expect(created_event.team_id).to eq(team.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe '#conversation_resolved' do
|
describe '#conversation_resolved' do
|
||||||
it 'creates conversation_resolved event' do
|
it 'creates conversation_resolved event' do
|
||||||
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 0
|
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 0
|
||||||
@@ -18,6 +69,16 @@ describe ReportingEventListener do
|
|||||||
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
|
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'sets from_state to handling' do
|
||||||
|
event = Events::Base.new('conversation.resolved', Time.zone.now, conversation: conversation)
|
||||||
|
listener.conversation_resolved(event)
|
||||||
|
|
||||||
|
resolved_event = account.reporting_events.find_by(name: 'conversation_resolved')
|
||||||
|
expect(resolved_event.from_state).to eq('handling')
|
||||||
|
expect(resolved_event.conversation_created_at).to be_within(1.second).of(conversation.created_at)
|
||||||
|
expect(resolved_event.channel_type).to eq(inbox.channel_type)
|
||||||
|
end
|
||||||
|
|
||||||
context 'when business hours enabled for inbox' do
|
context 'when business hours enabled for inbox' do
|
||||||
let(:created_at) { Time.zone.parse('March 20, 2022 00:00') }
|
let(:created_at) { Time.zone.parse('March 20, 2022 00:00') }
|
||||||
let(:updated_at) { Time.zone.parse('March 26, 2022 23:59') }
|
let(:updated_at) { Time.zone.parse('March 26, 2022 23:59') }
|
||||||
@@ -246,6 +307,9 @@ describe ReportingEventListener do
|
|||||||
listener.conversation_bot_handoff(event)
|
listener.conversation_bot_handoff(event)
|
||||||
expect(account.reporting_events.where(name: 'conversation_bot_handoff').count).to be 1
|
expect(account.reporting_events.where(name: 'conversation_bot_handoff').count).to be 1
|
||||||
|
|
||||||
|
handoff_event = account.reporting_events.find_by(name: 'conversation_bot_handoff')
|
||||||
|
expect(handoff_event.from_state).to eq('bot_handling')
|
||||||
|
|
||||||
# add extra handoff event for the same and ensure it's not created
|
# add extra handoff event for the same and ensure it's not created
|
||||||
event = Events::Base.new('conversation.bot_handoff', Time.zone.now, conversation: conversation)
|
event = Events::Base.new('conversation.bot_handoff', Time.zone.now, conversation: conversation)
|
||||||
listener.conversation_bot_handoff(event)
|
listener.conversation_bot_handoff(event)
|
||||||
@@ -272,7 +336,7 @@ describe ReportingEventListener do
|
|||||||
context 'when conversation is opened for the first time' do
|
context 'when conversation is opened for the first time' do
|
||||||
let(:new_conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
let(:new_conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||||
|
|
||||||
it 'creates conversation_opened event with value 0' do
|
it 'creates conversation_opened event with value 0 and nil from_state' do
|
||||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
|
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
|
||||||
event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
|
event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
|
||||||
listener.conversation_opened(event)
|
listener.conversation_opened(event)
|
||||||
@@ -283,6 +347,7 @@ describe ReportingEventListener do
|
|||||||
expect(opened_event.value_in_business_hours).to eq 0
|
expect(opened_event.value_in_business_hours).to eq 0
|
||||||
expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
|
expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
|
||||||
expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
|
expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
|
||||||
|
expect(opened_event.from_state).to be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -332,6 +397,7 @@ describe ReportingEventListener do
|
|||||||
expect(reopened_event.inbox_id).to eq(inbox.id)
|
expect(reopened_event.inbox_id).to eq(inbox.id)
|
||||||
expect(reopened_event.conversation_id).to eq(reopened_conversation.id)
|
expect(reopened_event.conversation_id).to eq(reopened_conversation.id)
|
||||||
expect(reopened_event.user_id).to eq(user.id)
|
expect(reopened_event.user_id).to eq(user.id)
|
||||||
|
expect(reopened_event.from_state).to eq('resolved')
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when business hours enabled for inbox' do
|
context 'when business hours enabled for inbox' do
|
||||||
|
|||||||
Reference in New Issue
Block a user