feat: label reports overview (#11194)

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Shivam Mishra
2025-06-11 14:35:46 +05:30
committed by GitHub
parent c0d9533b3a
commit 35f06f30e7
19 changed files with 1095 additions and 7 deletions

View File

@@ -0,0 +1,101 @@
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :account, :params
# rubocop:disable Lint/MissingSuper
# the parent class has no initialize
def initialize(account:, params:)
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
end
# rubocop:enable Lint/MissingSuper
def build
labels = account.labels.to_a
return [] if labels.empty?
report_data = collect_report_data
labels.map { |label| build_label_report(label, report_data) }
end
private
def collect_report_data
conversation_filter = build_conversation_filter
use_business_hours = use_business_hours?
{
conversation_counts: fetch_conversation_counts(conversation_filter),
resolved_counts: fetch_resolved_counts(conversation_filter),
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours)
}
end
def build_label_report(label, report_data)
{
id: label.id,
name: label.title,
conversations_count: report_data[:conversation_counts][label.title] || 0,
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
}
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
def build_conversation_filter
conversation_filter = { account_id: account.id }
conversation_filter[:created_at] = range if range.present?
conversation_filter
end
def fetch_conversation_counts(conversation_filter)
fetch_counts(conversation_filter)
end
def fetch_resolved_counts(conversation_filter)
fetch_counts(conversation_filter.merge(status: :resolved))
end
def fetch_counts(conversation_filter)
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
taggable_type: 'Conversation',
context: 'labels',
conversations: conversation_filter
)
.select('tags.name, COUNT(taggings.*) AS count')
.group('tags.name')
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
end
def fetch_metrics(conversation_filter, event_name, use_business_hours)
ReportingEvent
.joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
.joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
conversations: conversation_filter,
name: event_name,
taggings: { taggable_type: 'Conversation', context: 'labels' }
)
.group('tags.name')
.order('tags.name')
.select(
'tags.name',
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
)
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
end
end

View File

@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::InboxSummaryBuilder)
end
def label
render_report_with(V2::Reports::LabelSummaryBuilder)
end
private
def check_authorization

View File

@@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper
end
def generate_labels_report
Current.account.labels.map do |label|
label_report = report_builder({ type: :label, id: label.id }).short_summary
[label.title] + generate_readable_report_metrics(label_report)
reports = V2::Reports::LabelSummaryBuilder.new(
account: Current.account,
params: build_params({})
).build
reports.map do |report|
[report[:name]] + generate_readable_report_metrics(report)
end
end

View File

@@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient {
},
});
}
getLabelReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/label`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();

View File

@@ -87,7 +87,7 @@ const newReportRoutes = () => [
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
to: accountScopedRoute('label_reports'),
to: accountScopedRoute('label_reports_index'),
},
{
name: 'Reports Inbox',

View File

@@ -193,6 +193,7 @@
},
"LABEL_REPORTS": {
"HEADER": "Labels Overview",
"DESCRIPTION": "Track label performance with key metrics including conversations, response times, resolution times, and resolved cases. Click a label name for detailed insights.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
@@ -559,6 +560,7 @@
"INBOX": "Inbox",
"AGENT": "Agent",
"TEAM": "Team",
"LABEL": "Label",
"AVG_RESOLUTION_TIME": "Avg. Resolution Time",
"AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time",
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",

View File

@@ -0,0 +1,35 @@
<script setup>
import { ref } from 'vue';
import ReportHeader from './components/ReportHeader.vue';
import SummaryReports from './components/SummaryReports.vue';
import V4Button from 'dashboard/components-next/button/Button.vue';
const summarReportsRef = ref(null);
const onDownloadClick = () => {
summarReportsRef.value.downloadReports();
};
</script>
<template>
<ReportHeader
:header-title="$t('LABEL_REPORTS.HEADER')"
:header-description="$t('LABEL_REPORTS.DESCRIPTION')"
>
<V4Button
:label="$t('LABEL_REPORTS.DOWNLOAD_LABEL_REPORTS')"
icon="i-ph-download-simple"
size="sm"
@click="onDownloadClick"
/>
</ReportHeader>
<SummaryReports
ref="summarReportsRef"
action-key="summaryReports/fetchLabelSummaryReports"
getter-key="labels/getLabels"
fetch-items-key="labels/get"
summary-key="summaryReports/getLabelSummaryReports"
type="label"
/>
</template>

View File

@@ -0,0 +1,31 @@
<script setup>
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
import WootReports from './components/WootReports.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const route = useRoute();
const store = useStore();
const label = useFunctionGetter('labels/getLabelById', route.params.id);
onMounted(() => store.dispatch('labels/get'));
</script>
<template>
<WootReports
v-if="label.id"
:key="label.id"
type="label"
getter-key="labels/getLabels"
action-key="labels/get"
:selected-item="label"
:download-button-label="$t('LABEL_REPORTS.DOWNLOAD_LABEL_REPORTS')"
:report-title="$t('LABEL_REPORTS.HEADER')"
has-back-button
/>
<div v-else class="w-full py-20">
<Spinner class="mx-auto" />
</div>
</template>

View File

@@ -108,7 +108,8 @@ const tableData = computed(() =>
} = rowMetrics;
return {
id: row.id,
name: row.name,
// we fallback on title, label for instance does not have a name property
name: row.name ?? row.title,
type: props.type,
conversationsCount: renderCount(conversationsCount),
avgFirstResponseTime: renderAvgTime(avgFirstResponseTime),
@@ -177,7 +178,7 @@ defineExpose({ downloadReports });
<template>
<ReportFilterSelector @filter-change="onFilterChange" />
<div
class="flex-1 overflow-auto px-5 py-6 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
class="flex-1 overflow-auto px-2 py-2 mt-5 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2"
>
<Table :table="table" />
</div>

View File

@@ -7,10 +7,12 @@ import Index from './Index.vue';
import AgentReportsIndex from './AgentReportsIndex.vue';
import InboxReportsIndex from './InboxReportsIndex.vue';
import TeamReportsIndex from './TeamReportsIndex.vue';
import LabelReportsIndex from './LabelReportsIndex.vue';
import AgentReportsShow from './AgentReportsShow.vue';
import InboxReportsShow from './InboxReportsShow.vue';
import TeamReportsShow from './TeamReportsShow.vue';
import LabelReportsShow from './LabelReportsShow.vue';
import AgentReports from './AgentReports.vue';
import InboxReports from './InboxReports.vue';
@@ -104,6 +106,22 @@ const revisedReportRoutes = [
},
component: TeamReportsShow,
},
{
path: 'labels_overview',
name: 'label_reports_index',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: LabelReportsIndex,
},
{
path: 'labels/:id',
name: 'label_reports_show',
meta: {
permissions: ['administrator', 'report_manage'],
},
component: LabelReportsShow,
},
];
export default {

View File

@@ -26,6 +26,9 @@ export const getters = {
.filter(record => record.show_on_sidebar)
.sort((a, b) => a.title.localeCompare(b.title));
},
getLabelById: _state => id => {
return _state.records.find(record => record.id === Number(id));
},
};
export const actions = {

View File

@@ -7,6 +7,7 @@ vi.mock('dashboard/api/summaryReports', () => ({
getInboxReports: vi.fn(),
getAgentReports: vi.fn(),
getTeamReports: vi.fn(),
getLabelReports: vi.fn(),
},
}));
@@ -25,10 +26,12 @@ describe('Summary Reports Store', () => {
inboxSummaryReports: [],
agentSummaryReports: [],
teamSummaryReports: [],
labelSummaryReports: [],
uiFlags: {
isFetchingInboxSummaryReports: false,
isFetchingAgentSummaryReports: false,
isFetchingTeamSummaryReports: false,
isFetchingLabelSummaryReports: false,
},
});
});
@@ -39,6 +42,7 @@ describe('Summary Reports Store', () => {
inboxSummaryReports: [{ id: 1 }],
agentSummaryReports: [{ id: 2 }],
teamSummaryReports: [{ id: 3 }],
labelSummaryReports: [{ id: 4 }],
uiFlags: { isFetchingInboxSummaryReports: true },
};
@@ -54,6 +58,10 @@ describe('Summary Reports Store', () => {
expect(store.getters.getTeamSummaryReports(state)).toEqual([{ id: 3 }]);
});
it('should return label summary reports', () => {
expect(store.getters.getLabelSummaryReports(state)).toEqual([{ id: 4 }]);
});
it('should return UI flags', () => {
expect(store.getters.getUIFlags(state)).toEqual({
isFetchingInboxSummaryReports: true,
@@ -86,6 +94,14 @@ describe('Summary Reports Store', () => {
expect(state.teamSummaryReports).toEqual(data);
});
it('should set label summary report', () => {
const state = { ...initialState };
const data = [{ id: 4 }];
store.mutations.setLabelSummaryReport(state, data);
expect(state.labelSummaryReports).toEqual(data);
});
it('should merge UI flags with existing flags', () => {
const state = {
uiFlags: { flag1: true, flag2: false },
@@ -185,5 +201,29 @@ describe('Summary Reports Store', () => {
});
});
});
describe('fetchLabelSummaryReports', () => {
it('should fetch label reports successfully', async () => {
const params = { labelId: 789 };
const mockResponse = {
data: [{ label_id: 789, label_name: 'Test Label' }],
};
SummaryReportsAPI.getLabelReports.mockResolvedValue(mockResponse);
await store.actions.fetchLabelSummaryReports({ commit }, params);
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingLabelSummaryReports: true,
});
expect(SummaryReportsAPI.getLabelReports).toHaveBeenCalledWith(params);
expect(commit).toHaveBeenCalledWith('setLabelSummaryReport', [
{ labelId: 789, labelName: 'Test Label' },
]);
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingLabelSummaryReports: false,
});
});
});
});
});

View File

@@ -17,6 +17,11 @@ const typeMap = {
apiMethod: 'getTeamReports',
mutationKey: 'setTeamSummaryReport',
},
label: {
flagKey: 'isFetchingLabelSummaryReports',
apiMethod: 'getLabelReports',
mutationKey: 'setLabelSummaryReport',
},
};
async function fetchSummaryReports(type, params, { commit }) {
@@ -38,10 +43,12 @@ export const initialState = {
inboxSummaryReports: [],
agentSummaryReports: [],
teamSummaryReports: [],
labelSummaryReports: [],
uiFlags: {
isFetchingInboxSummaryReports: false,
isFetchingAgentSummaryReports: false,
isFetchingTeamSummaryReports: false,
isFetchingLabelSummaryReports: false,
},
};
@@ -55,6 +62,9 @@ export const getters = {
getTeamSummaryReports(state) {
return state.teamSummaryReports;
},
getLabelSummaryReports(state) {
return state.labelSummaryReports;
},
getUIFlags(state) {
return state.uiFlags;
},
@@ -72,6 +82,10 @@ export const actions = {
fetchTeamSummaryReports({ commit }, params) {
return fetchSummaryReports('team', params, { commit });
},
fetchLabelSummaryReports({ commit }, params) {
return fetchSummaryReports('label', params, { commit });
},
};
export const mutations = {
@@ -84,6 +98,9 @@ export const mutations = {
setTeamSummaryReport(state, data) {
state.teamSummaryReports = data;
},
setLabelSummaryReport(state, data) {
state.labelSummaryReports = data;
},
setUIFlags(state, uiFlag) {
state.uiFlags = { ...state.uiFlags, ...uiFlag };
},

View File

@@ -344,6 +344,7 @@ Rails.application.routes.draw do
get :agent
get :team
get :inbox
get :label
end
end
resources :reports, only: [:index] do

View File

@@ -0,0 +1,105 @@
# frozen_string_literal: true
require 'faker'
require 'active_support/testing/time_helpers'
class Seeders::Reports::ConversationCreator
include ActiveSupport::Testing::TimeHelpers
def initialize(account:, resources:)
@account = account
@contacts = resources[:contacts]
@inboxes = resources[:inboxes]
@teams = resources[:teams]
@labels = resources[:labels]
@agents = resources[:agents]
@priorities = [nil, 'urgent', 'high', 'medium', 'low']
end
def create_conversation(created_at:)
conversation = nil
ActiveRecord::Base.transaction do
travel_to(created_at) do
conversation = build_conversation
conversation.save!
add_labels_to_conversation(conversation)
create_messages_for_conversation(conversation)
resolve_conversation_if_needed(conversation)
end
travel_back
end
conversation
end
private
def build_conversation
contact = @contacts.sample
inbox = @inboxes.sample
contact_inbox = find_or_create_contact_inbox(contact, inbox)
assignee = select_assignee(inbox)
team = select_team
priority = @priorities.sample
contact_inbox.conversations.new(
account: @account,
inbox: inbox,
contact: contact,
assignee: assignee,
team: team,
priority: priority
)
end
def find_or_create_contact_inbox(contact, inbox)
inbox.contact_inboxes.find_or_create_by!(
contact: contact,
source_id: SecureRandom.hex
)
end
def select_assignee(inbox)
rand(10) < 8 ? inbox.members.sample : nil
end
def select_team
rand(10) < 7 ? @teams.sample : nil
end
def add_labels_to_conversation(conversation)
labels_to_add = @labels.sample(rand(5..20))
conversation.update_labels(labels_to_add.map(&:title))
end
def create_messages_for_conversation(conversation)
message_creator = Seeders::Reports::MessageCreator.new(
account: @account,
agents: @agents,
conversation: conversation
)
message_creator.create_messages
end
def resolve_conversation_if_needed(conversation)
return unless rand < 0.7
resolution_delay = rand((30.minutes)..(24.hours))
travel(resolution_delay)
conversation.update!(status: :resolved)
trigger_conversation_resolved_event(conversation)
end
def trigger_conversation_resolved_event(conversation)
event_data = { conversation: conversation }
ReportingEventListener.instance.conversation_resolved(
Events::Base.new('conversation_resolved', Time.current, event_data)
)
end
end

View File

@@ -0,0 +1,141 @@
# frozen_string_literal: true
require 'faker'
require 'active_support/testing/time_helpers'
class Seeders::Reports::MessageCreator
include ActiveSupport::Testing::TimeHelpers
MESSAGES_PER_CONVERSATION = 5
def initialize(account:, agents:, conversation:)
@account = account
@agents = agents
@conversation = conversation
end
def create_messages
message_count = rand(MESSAGES_PER_CONVERSATION..MESSAGES_PER_CONVERSATION + 5)
first_agent_reply = true
message_count.times do |i|
message = create_single_message(i)
first_agent_reply = handle_reply_tracking(message, i, first_agent_reply)
end
end
def create_single_message(index)
is_incoming = index.even?
add_realistic_delay(index, is_incoming) if index.positive?
create_message(is_incoming)
end
def handle_reply_tracking(message, index, first_agent_reply)
return first_agent_reply if index.even? # Skip incoming messages
handle_agent_reply_events(message, first_agent_reply)
false # No longer first reply after any agent message
end
private
def add_realistic_delay(_message_index, is_incoming)
delay = calculate_message_delay(is_incoming)
travel(delay)
end
def calculate_message_delay(is_incoming)
if is_incoming
# Customer response time: 1 minute to 4 hours
rand((1.minute)..(4.hours))
elsif business_hours_active?(Time.current)
# Agent response time varies by business hours
rand((30.seconds)..(30.minutes))
else
rand((1.hour)..(8.hours))
end
end
def create_message(is_incoming)
if is_incoming
create_incoming_message
else
create_outgoing_message
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
sender = @conversation.assignee || @agents.sample
@conversation.messages.create!(
account: @account,
inbox: @conversation.inbox,
message_type: :outgoing,
content: generate_message_content,
sender: sender
)
end
def generate_message_content
Faker::Lorem.paragraph(sentence_count: rand(1..5))
end
def handle_agent_reply_events(message, is_first_reply)
if is_first_reply
trigger_first_reply_event(message)
else
trigger_reply_event(message)
end
end
def business_hours_active?(time)
weekday = time.wday
hour = time.hour
weekday.between?(1, 5) && hour.between?(9, 17)
end
def trigger_first_reply_event(message)
event_data = {
message: message,
conversation: message.conversation
}
ReportingEventListener.instance.first_reply_created(
Events::Base.new('first_reply_created', Time.current, event_data)
)
end
def trigger_reply_event(message)
waiting_since = calculate_waiting_since(message)
event_data = {
message: message,
conversation: message.conversation,
waiting_since: waiting_since
}
ReportingEventListener.instance.reply_created(
Events::Base.new('reply_created', Time.current, event_data)
)
end
def calculate_waiting_since(message)
last_customer_message = message.conversation.messages
.where(message_type: :incoming)
.where('created_at < ?', message.created_at)
.order(:created_at)
.last
last_customer_message&.created_at || message.conversation.created_at
end
end

View File

@@ -0,0 +1,234 @@
# frozen_string_literal: true
# Reports Data Seeder
#
# Generates realistic test data for performance testing of reports and analytics.
# Creates conversations, messages, contacts, agents, teams, and labels with proper
# reporting events (first response times, resolution times, etc.) using time travel
# to generate historical data with realistic timestamps.
#
# Usage:
# ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data
#
# This will create:
# - 1000 conversations with realistic message exchanges
# - 100 contacts with realistic profiles
# - 20 agents assigned to teams and inboxes
# - 5 teams with realistic distribution
# - 30 labels with random assignments
# - 3 inboxes with agent assignments
# - Realistic reporting events with historical timestamps
#
# Note: This seeder clears existing data for the account before seeding.
require 'faker'
require_relative 'conversation_creator'
require_relative 'message_creator'
# rubocop:disable Rails/Output
class Seeders::Reports::ReportDataSeeder
include ActiveSupport::Testing::TimeHelpers
TOTAL_CONVERSATIONS = 1000
TOTAL_CONTACTS = 100
TOTAL_AGENTS = 20
TOTAL_TEAMS = 5
TOTAL_LABELS = 30
TOTAL_INBOXES = 3
MESSAGES_PER_CONVERSATION = 5
START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
END_DATE = Time.current
def initialize(account:)
raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
@account = account
@teams = []
@agents = []
@labels = []
@inboxes = []
@contacts = []
end
def perform!
puts "Starting reports data seeding for account: #{@account.name}"
# Clear existing data
clear_existing_data
create_teams
create_agents
create_labels
create_inboxes
create_contacts
create_conversations
puts "Completed reports data seeding for account: #{@account.name}"
end
private
def clear_existing_data
puts "Clearing existing data for account: #{@account.id}"
@account.teams.destroy_all
@account.conversations.destroy_all
@account.labels.destroy_all
@account.inboxes.destroy_all
@account.contacts.destroy_all
@account.agents.destroy_all
@account.reporting_events.destroy_all
end
def create_teams
TOTAL_TEAMS.times do |i|
team = @account.teams.create!(
name: "#{Faker::Company.industry} Team #{i + 1}"
)
@teams << team
print "\rCreating teams: #{i + 1}/#{TOTAL_TEAMS}"
end
print "\n"
end
def create_agents
TOTAL_AGENTS.times do |i|
user = create_single_agent(i)
assign_agent_to_teams(user)
@agents << user
print "\rCreating agents: #{i + 1}/#{TOTAL_AGENTS}"
end
print "\n"
end
def create_single_agent(index)
random_suffix = SecureRandom.hex(4)
user = User.create!(
name: Faker::Name.name,
email: "agent_#{index + 1}_#{random_suffix}@#{@account.domain || 'example.com'}",
password: 'Password1!.',
confirmed_at: Time.current
)
user.skip_confirmation!
user.save!
AccountUser.create!(
account_id: @account.id,
user_id: user.id,
role: :agent
)
user
end
def assign_agent_to_teams(user)
teams_to_assign = @teams.sample(rand(1..3))
teams_to_assign.each do |team|
TeamMember.create!(
team_id: team.id,
user_id: user.id
)
end
end
def create_labels
TOTAL_LABELS.times do |i|
label = @account.labels.create!(
title: "Label-#{i + 1}-#{Faker::Lorem.word}",
description: Faker::Company.catch_phrase,
color: Faker::Color.hex_color
)
@labels << label
print "\rCreating labels: #{i + 1}/#{TOTAL_LABELS}"
end
print "\n"
end
def create_inboxes
TOTAL_INBOXES.times do |_i|
inbox = create_single_inbox
assign_agents_to_inbox(inbox)
@inboxes << inbox
print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}"
end
print "\n"
end
def create_single_inbox
channel = Channel::WebWidget.create!(
website_url: "https://#{Faker::Internet.domain_name}",
account_id: @account.id
)
@account.inboxes.create!(
name: "#{Faker::Company.name} Website",
channel: channel
)
end
def assign_agents_to_inbox(inbox)
agents_to_assign = if @inboxes.empty?
# First inbox gets all agents to ensure coverage
@agents
else
# Subsequent inboxes get random selection with some overlap
min_agents = [@agents.size / TOTAL_INBOXES, 10].max
max_agents = [(@agents.size * 0.8).to_i, 50].min
@agents.sample(rand(min_agents..max_agents))
end
agents_to_assign.each do |agent|
InboxMember.create!(inbox: inbox, user: agent)
end
end
def create_contacts
TOTAL_CONTACTS.times do |i|
contact = @account.contacts.create!(
name: Faker::Name.name,
email: Faker::Internet.email,
phone_number: Faker::PhoneNumber.cell_phone_in_e164,
identifier: SecureRandom.uuid,
additional_attributes: {
company: Faker::Company.name,
city: Faker::Address.city,
country: Faker::Address.country,
customer_since: Faker::Date.between(from: 2.years.ago, to: Time.zone.today)
}
)
@contacts << contact
print "\rCreating contacts: #{i + 1}/#{TOTAL_CONTACTS}"
end
print "\n"
end
def create_conversations
conversation_creator = Seeders::Reports::ConversationCreator.new(
account: @account,
resources: {
contacts: @contacts,
inboxes: @inboxes,
teams: @teams,
labels: @labels,
agents: @agents
}
)
TOTAL_CONVERSATIONS.times do |i|
created_at = Faker::Time.between(from: START_DATE, to: END_DATE)
conversation_creator.create_conversation(created_at: created_at)
completion_percentage = ((i + 1).to_f / TOTAL_CONVERSATIONS * 100).round
print "\rCreating conversations: #{i + 1}/#{TOTAL_CONVERSATIONS} (#{completion_percentage}%)"
end
print "\n"
end
end
# rubocop:enable Rails/Output

View File

@@ -0,0 +1,24 @@
namespace :db do
namespace :seed do
desc 'Seed test data for reports with conversations, contacts, agents, teams, and realistic reporting events'
task reports_data: :environment do
if ENV['ACCOUNT_ID'].blank?
puts 'Please provide an ACCOUNT_ID environment variable'
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_id = ENV.fetch('ACCOUNT_ID', nil)
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!
puts "Finished seeding reports data for account: #{account.name}"
end
end
end

View File

@@ -0,0 +1,317 @@
require 'rails_helper'
RSpec.describe V2::Reports::LabelSummaryBuilder do
include ActiveJob::TestHelper
let_it_be(:account) { create(:account) }
let_it_be(:label_1) { create(:label, title: 'label_1', account: account) }
let_it_be(:label_2) { create(:label, title: 'label_2', account: account) }
let_it_be(:label_3) { create(:label, title: 'label_3', account: account) }
let(:params) do
{
business_hours: business_hours,
since: (Time.zone.today - 3.days).to_time.to_i.to_s,
until: Time.zone.today.end_of_day.to_time.to_i.to_s,
timezone_offset: 0
}
end
let(:builder) { described_class.new(account: account, params: params) }
describe '#initialize' do
let(:business_hours) { false }
it 'sets account and params' do
expect(builder.account).to eq(account)
expect(builder.params).to eq(params)
end
it 'sets timezone from timezone_offset' do
builder_with_offset = described_class.new(account: account, params: { timezone_offset: -8 })
expect(builder_with_offset.instance_variable_get(:@timezone)).to eq('Pacific Time (US & Canada)')
end
it 'defaults timezone when timezone_offset is not provided' do
builder_without_offset = described_class.new(account: account, params: {})
expect(builder_without_offset.instance_variable_get(:@timezone)).not_to be_nil
end
end
describe '#build' do
context 'when there are no labels' do
let(:business_hours) { false }
let(:empty_account) { create(:account) }
let(:empty_builder) { described_class.new(account: empty_account, params: params) }
it 'returns empty array' do
expect(empty_builder.build).to eq([])
end
end
context 'when there are labels but no conversations' do
let(:business_hours) { false }
it 'returns zero values for all labels' do
report = builder.build
expect(report.length).to eq(3)
bug_report = report.find { |r| r[:name] == 'label_1' }
feature_request = report.find { |r| r[:name] == 'label_2' }
customer_support = report.find { |r| r[:name] == 'label_3' }
[
[bug_report, label_1, 'label_1'],
[feature_request, label_2, 'label_2'],
[customer_support, label_3, 'label_3']
].each do |report_data, label, label_name|
expect(report_data).to include(
id: label.id,
name: label_name,
conversations_count: 0,
avg_resolution_time: 0,
avg_first_response_time: 0,
avg_reply_time: 0,
resolved_conversations_count: 0
)
end
end
end
context 'when there are labeled conversations with metrics' do
before do
travel_to(Time.zone.today) do
user = create(:user, account: account)
inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
perform_enqueued_jobs do
# Create conversations with label_1
3.times do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: Time.zone.today)
create_list(:message, 2, message_type: 'outgoing',
account: account, inbox: inbox,
conversation: conversation,
created_at: Time.zone.today + 1.hour)
create_list(:message, 1, message_type: 'incoming',
account: account, inbox: inbox,
conversation: conversation,
created_at: Time.zone.today + 2.hours)
conversation.update_labels('label_1')
conversation.label_list
conversation.save!
end
# Create conversations with label_2
2.times do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: Time.zone.today)
create_list(:message, 1, message_type: 'outgoing',
account: account, inbox: inbox,
conversation: conversation,
created_at: Time.zone.today + 1.hour)
conversation.update_labels('label_2')
conversation.label_list
conversation.save!
end
# Resolve some conversations
conversations_to_resolve = account.conversations.first(2)
conversations_to_resolve.each(&:toggle_status)
# Create some reporting events
account.conversations.reload.each_with_index do |conv, idx|
# First response times
create(:reporting_event,
account: account,
conversation: conv,
name: 'first_response',
value: (30 + (idx * 10)) * 60,
value_in_business_hours: (20 + (idx * 5)) * 60,
created_at: Time.zone.today)
# Reply times
create(:reporting_event,
account: account,
conversation: conv,
name: 'reply',
value: (15 + (idx * 5)) * 60,
value_in_business_hours: (10 + (idx * 3)) * 60,
created_at: Time.zone.today)
# Resolution times for resolved conversations
next unless conv.resolved?
create(:reporting_event,
account: account,
conversation: conv,
name: 'conversation_resolved',
value: (60 + (idx * 30)) * 60,
value_in_business_hours: (45 + (idx * 20)) * 60,
created_at: Time.zone.today)
end
end
end
end
context 'when business hours is disabled' do
let(:business_hours) { false }
it 'returns correct label stats using regular values' do
report = builder.build
expect(report.length).to eq(3)
label_1_report = report.find { |r| r[:name] == 'label_1' }
label_2_report = report.find { |r| r[:name] == 'label_2' }
label_3_report = report.find { |r| r[:name] == 'label_3' }
expect(label_1_report).to include(
conversations_count: 3,
avg_first_response_time: be > 0,
avg_reply_time: be > 0
)
expect(label_2_report).to include(
conversations_count: 2,
avg_first_response_time: be > 0,
avg_reply_time: be > 0
)
expect(label_3_report).to include(
conversations_count: 0,
avg_first_response_time: 0,
avg_reply_time: 0
)
end
end
context 'when business hours is enabled' do
let(:business_hours) { true }
it 'returns correct label stats using business hours values' do
report = builder.build
expect(report.length).to eq(3)
label_1_report = report.find { |r| r[:name] == 'label_1' }
label_2_report = report.find { |r| r[:name] == 'label_2' }
expect(label_1_report[:conversations_count]).to eq(3)
expect(label_1_report[:avg_first_response_time]).to be > 0
expect(label_1_report[:avg_reply_time]).to be > 0
expect(label_2_report[:conversations_count]).to eq(2)
expect(label_2_report[:avg_first_response_time]).to be > 0
expect(label_2_report[:avg_reply_time]).to be > 0
end
end
end
context 'when filtering by date range' do
let(:business_hours) { false }
before do
travel_to(Time.zone.today) do
user = create(:user, account: account)
inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
perform_enqueued_jobs do
# Conversation within range
conversation_in_range = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: 2.days.ago)
conversation_in_range.update_labels('label_1')
conversation_in_range.label_list
conversation_in_range.save!
create(:reporting_event,
account: account,
conversation: conversation_in_range,
name: 'first_response',
value: 1800,
created_at: 2.days.ago)
# Conversation outside range (too old)
conversation_out_of_range = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: 1.week.ago)
conversation_out_of_range.update_labels('label_1')
conversation_out_of_range.label_list
conversation_out_of_range.save!
create(:reporting_event,
account: account,
conversation: conversation_out_of_range,
name: 'first_response',
value: 3600,
created_at: 1.week.ago)
end
end
end
it 'only includes conversations within the date range' do
report = builder.build
expect(report.length).to eq(3)
label_1_report = report.find { |r| r[:name] == 'label_1' }
expect(label_1_report).not_to be_nil
expect(label_1_report[:conversations_count]).to eq(1)
expect(label_1_report[:avg_first_response_time]).to eq(1800.0)
end
end
context 'with business hours parameter' do
let(:business_hours) { 'true' }
before do
travel_to(Time.zone.today) do
user = create(:user, account: account)
inbox = create(:inbox, account: account)
create(:inbox_member, user: user, inbox: inbox)
gravatar_url = 'https://www.gravatar.com'
stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
perform_enqueued_jobs do
conversation = create(:conversation, account: account,
inbox: inbox, assignee: user,
created_at: Time.zone.today)
conversation.update_labels('label_1')
conversation.label_list
conversation.save!
create(:reporting_event,
account: account,
conversation: conversation,
name: 'first_response',
value: 3600,
value_in_business_hours: 1800,
created_at: Time.zone.today)
end
end
end
it 'properly casts string "true" to boolean and uses business hours values' do
report = builder.build
expect(report.length).to eq(3)
label_1_report = report.find { |r| r[:name] == 'label_1' }
expect(label_1_report).not_to be_nil
expect(label_1_report[:avg_first_response_time]).to eq(1800.0)
end
end
end
end