diff --git a/app/builders/v2/reports/inbox_leads_summary_builder.rb b/app/builders/v2/reports/inbox_leads_summary_builder.rb new file mode 100644 index 000000000..053b53596 --- /dev/null +++ b/app/builders/v2/reports/inbox_leads_summary_builder.rb @@ -0,0 +1,111 @@ +class V2::Reports::InboxLeadsSummaryBuilder + include DateRangeHelper + + ALLOWED_GROUP_BY = %w[day week month].freeze + RETURN_THRESHOLD = '24 hours'.freeze + + attr_reader :account, :params + + def initialize(account, params) + @account = account + @params = params + end + + def build + return [] if range.blank? || inbox.blank? + + rows = ActiveRecord::Base.connection.exec_query( + ActiveRecord::Base.sanitize_sql_array([sql, sql_bindings]) + ) + + rows.map do |row| + { + period: row['period'].iso8601, + new_leads: row['new_leads'].to_i, + returning: row['returning'].to_i, + others: row['others'].to_i + } + end + end + + private + + def inbox + @inbox ||= account.inboxes.find_by(id: params[:inbox_id]) + end + + def group_by + value = params[:group_by].to_s + ALLOWED_GROUP_BY.include?(value) ? value : 'day' + end + + def sql_bindings + { + account_id: account.id, + inbox_id: inbox.id, + since: range.begin, + until_t: range.end, + group_by: group_by, + return_threshold: RETURN_THRESHOLD + } + end + + # Single CTE to classify each conversation in the period as: + # * new_leads: contact has no prior conversation in any inbox of the account + # * returning: contact had a prior conversation whose latest 'conversation_resolved' + # event occurred more than 24h before the new conversation + # * others: prior conversation existed but was not resolved or was resolved <24h ago + # rubocop:disable Metrics/MethodLength + def sql + <<~SQL.squish + WITH period_conversations AS ( + SELECT id, contact_id, created_at + FROM conversations + WHERE account_id = :account_id + AND inbox_id = :inbox_id + AND created_at >= :since + AND created_at < :until_t + ), + classified AS ( + SELECT + c.id, + c.created_at, + EXISTS ( + SELECT 1 FROM conversations prev + WHERE prev.contact_id = c.contact_id + AND prev.account_id = :account_id + AND prev.id < c.id + ) AS has_prior, + ( + SELECT MAX(re.created_at) + FROM reporting_events re + INNER JOIN conversations prev ON prev.id = re.conversation_id + WHERE re.name = 'conversation_resolved' + AND prev.contact_id = c.contact_id + AND prev.account_id = :account_id + AND prev.id < c.id + ) AS latest_prior_resolution_at + FROM period_conversations c + ) + SELECT + date_trunc(:group_by, created_at) AS period, + COUNT(*) FILTER (WHERE NOT has_prior) AS new_leads, + COUNT(*) FILTER ( + WHERE has_prior + AND latest_prior_resolution_at IS NOT NULL + AND latest_prior_resolution_at < created_at - (:return_threshold)::interval + ) AS returning, + COUNT(*) FILTER ( + WHERE has_prior + AND ( + latest_prior_resolution_at IS NULL + OR latest_prior_resolution_at >= created_at - (:return_threshold)::interval + ) + ) AS others + FROM classified + GROUP BY period + ORDER BY period + SQL + end + # rubocop:enable Metrics/MethodLength +end diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 192b3619c..4fe1656e0 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -87,6 +87,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController render json: builder.build end + def inbox_leads_summary + return head :unprocessable_entity if params[:inbox_id].blank? + + builder = V2::Reports::InboxLeadsSummaryBuilder.new(Current.account, inbox_leads_summary_params) + render json: builder.build + end + private def generate_csv(filename, template) @@ -188,4 +195,13 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController until: params[:until] } end + + def inbox_leads_summary_params + { + inbox_id: params[:inbox_id], + group_by: params[:group_by], + since: params[:since], + until: params[:until] + } + end end diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 00f040f8e..55cf25bc8 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -108,6 +108,18 @@ class ReportsAPI extends ApiClient { }, }); } + + getInboxLeadsSummary({ inboxId, from, to, groupBy } = {}) { + return axios.get(`${this.url}/inbox_leads_summary`, { + params: { + inbox_id: inboxId, + since: from, + until: to, + group_by: groupBy, + timezone_offset: getTimeOffset(), + }, + }); + } } export default new ReportsAPI(); diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 2ffa0ef11..003cb72af 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -281,6 +281,33 @@ "FILTER_DROPDOWN_LABEL": "Select Inbox", "ALL_INBOXES": "All Inboxes", "SEARCH_INBOX": "Search Inbox", + "TABS": { + "OVERVIEW": "Overview", + "LEADS": "New × Returning" + }, + "LEADS": { + "EMPTY": "No conversations in this period.", + "TOTAL": "Total conversations in the period: {count}", + "METRICS": { + "NEW_LEADS": { + "LABEL": "New leads", + "INFO": "Conversations from contacts who never spoke to any inbox of the network before." + }, + "RETURNING": { + "LABEL": "Returning", + "INFO": "Conversations from contacts whose most recent prior conversation was resolved more than 24h ago." + }, + "OTHERS": { + "LABEL": "Others", + "INFO": "Conversations from contacts whose prior conversation is still open or was resolved less than 24h ago." + } + }, + "CHART": { + "NEW_LEADS": "New", + "RETURNING": "Returning", + "OTHERS": "Others" + } + }, "FILTERS": { "INPUT_PLACEHOLDER": { "INBOXES": "Search inboxes" diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/report.json b/app/javascript/dashboard/i18n/locale/pt_BR/report.json index 426e895c1..de38aaf93 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/report.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/report.json @@ -269,8 +269,35 @@ "NO_ENOUGH_DATA": "Não existem dados suficientes para gerar o relatório. Tente novamente mais tarde.", "DOWNLOAD_INBOX_REPORTS": "Baixar relatórios de entrada", "FILTER_DROPDOWN_LABEL": "Selecionar caixa de entrada", - "ALL_INBOXES": "All Inboxes", - "SEARCH_INBOX": "Search Inbox", + "ALL_INBOXES": "Todas as caixas", + "SEARCH_INBOX": "Buscar caixa", + "TABS": { + "OVERVIEW": "Visão Geral", + "LEADS": "Novas × Retorno" + }, + "LEADS": { + "EMPTY": "Sem conversas no período.", + "TOTAL": "Total de conversas no período: {count}", + "METRICS": { + "NEW_LEADS": { + "LABEL": "Leads novos", + "INFO": "Conversas de contatos que nunca falaram em nenhuma caixa da rede antes." + }, + "RETURNING": { + "LABEL": "Retorno", + "INFO": "Conversas de contatos cuja conversa anterior mais recente foi resolvida há mais de 24h." + }, + "OTHERS": { + "LABEL": "Outras", + "INFO": "Conversas de contatos cuja conversa anterior ainda está aberta ou foi resolvida há menos de 24h." + } + }, + "CHART": { + "NEW_LEADS": "Novas", + "RETURNING": "Retorno", + "OTHERS": "Outras" + } + }, "METRICS": { "CONVERSATIONS": { "NAME": "Conversas", diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/InboxReportsShow.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/InboxReportsShow.vue index 06b584aae..c2e4ccb10 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/InboxReportsShow.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/InboxReportsShow.vue @@ -1,26 +1,71 @@ - + + + + {{ $t('INBOX_REPORTS.TABS.OVERVIEW') }} + + + {{ $t('INBOX_REPORTS.TABS.LEADS') }} + + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/InboxLeadsReport.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/InboxLeadsReport.vue new file mode 100644 index 000000000..80bfa08a0 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/InboxLeadsReport.vue @@ -0,0 +1,156 @@ + + + + + + + + + + + + + + + + {{ $t('INBOX_REPORTS.LEADS.EMPTY') }} + + + + + + + + {{ $t('INBOX_REPORTS.LEADS.TOTAL', { count: totalConversations }) }} + + + diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js index bb62a9b17..2118a163d 100644 --- a/app/javascript/dashboard/store/modules/reports.js +++ b/app/javascript/dashboard/store/modules/reports.js @@ -67,6 +67,10 @@ const state = { agentConversationMetric: [], teamConversationMetric: [], }, + inboxLeadsSummary: { + isFetching: false, + data: [], + }, }; const getters = { @@ -103,6 +107,12 @@ const getters = { getOverviewUIFlags($state) { return $state.overview.uiFlags; }, + getInboxLeadsSummary(_state) { + return _state.inboxLeadsSummary.data; + }, + getInboxLeadsSummaryFetching(_state) { + return _state.inboxLeadsSummary.isFetching; + }, }; export const actions = { @@ -286,6 +296,20 @@ export const actions = { console.error(error); }); }, + fetchInboxLeadsSummary({ commit }, reportObj) { + commit(types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING, true); + return Report.getInboxLeadsSummary(reportObj) + .then(response => { + commit(types.default.SET_INBOX_LEADS_SUMMARY, response.data || []); + }) + .catch(error => { + console.error(error); + commit(types.default.SET_INBOX_LEADS_SUMMARY, []); + }) + .finally(() => { + commit(types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING, false); + }); + }, downloadAccountConversationHeatmap(_, reportObj) { Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore }) .then(response => { @@ -357,6 +381,12 @@ const mutations = { [types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) { _state.overview.uiFlags.isFetchingTeamConversationMetric = flag; }, + [types.default.SET_INBOX_LEADS_SUMMARY](_state, data) { + _state.inboxLeadsSummary.data = data; + }, + [types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING](_state, flag) { + _state.inboxLeadsSummary.isFetching = flag; + }, }; export default { diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 80fb98ea7..1082f95f6 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -209,6 +209,8 @@ export default { SET_AGENT_CONVERSATION_METRIC: 'SET_AGENT_CONVERSATION_METRIC', TOGGLE_AGENT_CONVERSATION_METRIC_LOADING: 'TOGGLE_AGENT_CONVERSATION_METRIC_LOADING', + SET_INBOX_LEADS_SUMMARY: 'SET_INBOX_LEADS_SUMMARY', + TOGGLE_INBOX_LEADS_SUMMARY_LOADING: 'TOGGLE_INBOX_LEADS_SUMMARY_LOADING', // Conversation Metadata SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA', diff --git a/config/routes.rb b/config/routes.rb index 8e17989e3..ab55dee1d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -515,6 +515,7 @@ Rails.application.routes.draw do get :inbox_label_matrix get :first_response_time_distribution get :outgoing_messages_count + get :inbox_leads_summary end end resource :year_in_review, only: [:show] diff --git a/spec/builders/v2/reports/inbox_leads_summary_builder_spec.rb b/spec/builders/v2/reports/inbox_leads_summary_builder_spec.rb new file mode 100644 index 000000000..dc036b4ca --- /dev/null +++ b/spec/builders/v2/reports/inbox_leads_summary_builder_spec.rb @@ -0,0 +1,141 @@ +require 'rails_helper' + +RSpec.describe V2::Reports::InboxLeadsSummaryBuilder do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:other_inbox) { create(:inbox, account: account) } + + let(:since_t) { 30.days.ago.beginning_of_day } + let(:until_t) { Time.current.end_of_day } + + let(:base_params) do + { + inbox_id: inbox.id, + since: since_t.to_i.to_s, + until: until_t.to_i.to_s, + group_by: 'day' + } + end + + def build(params = base_params) + described_class.new(account, params).build + end + + def total_for(rows, key) + rows.sum { |r| r[key] } + end + + describe '#build' do + context 'when no inbox is provided or invalid' do + it 'returns empty array when inbox_id missing' do + expect(build(base_params.except(:inbox_id))).to eq([]) + end + + it 'returns empty array when range missing' do + expect(build(base_params.except(:since))).to eq([]) + end + + it 'returns empty array when inbox does not belong to account' do + foreign_inbox = create(:inbox, account: create(:account)) + expect(build(base_params.merge(inbox_id: foreign_inbox.id))).to eq([]) + end + end + + context 'when classifying conversations' do + it 'counts as new_lead when contact has no prior conversation' do + contact = create(:contact, account: account) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 2.days.ago) + + rows = build + expect(total_for(rows, :new_leads)).to eq(1) + expect(total_for(rows, :returning)).to eq(0) + expect(total_for(rows, :others)).to eq(0) + end + + it 'counts as returning when prior conversation was resolved >24h ago' do + contact = create(:contact, account: account) + prior = create(:conversation, account: account, inbox: other_inbox, contact: contact, created_at: 10.days.ago) + create(:reporting_event, account: account, inbox: other_inbox, conversation: prior, + name: 'conversation_resolved', value: 100, value_in_business_hours: 50, + created_at: 5.days.ago) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago) + + rows = build + expect(total_for(rows, :new_leads)).to eq(0) + expect(total_for(rows, :returning)).to eq(1) + expect(total_for(rows, :others)).to eq(0) + end + + it 'counts as others when prior conversation was resolved <24h ago' do + contact = create(:contact, account: account) + prior = create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 2.days.ago) + create(:reporting_event, account: account, inbox: inbox, conversation: prior, + name: 'conversation_resolved', value: 100, value_in_business_hours: 50, + created_at: 3.hours.ago) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.hour.ago) + + rows = build + expect(total_for(rows, :new_leads)).to eq(0) + expect(total_for(rows, :returning)).to eq(0) + expect(total_for(rows, :others)).to eq(1) + end + + it 'counts as others when contact had prior conversation but it was never resolved' do + contact = create(:contact, account: account) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 5.days.ago) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago) + + rows = build + expect(total_for(rows, :new_leads)).to eq(1) # the older one + expect(total_for(rows, :returning)).to eq(0) + expect(total_for(rows, :others)).to eq(1) # the newer one + end + + it 'considers prior conversations from any inbox of the account (network-wide)' do + contact = create(:contact, account: account) + prior = create(:conversation, account: account, inbox: other_inbox, contact: contact, created_at: 10.days.ago) + create(:reporting_event, account: account, inbox: other_inbox, conversation: prior, + name: 'conversation_resolved', value: 100, value_in_business_hours: 50, + created_at: 5.days.ago) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago) + + rows = build + expect(total_for(rows, :new_leads)).to eq(0) + expect(total_for(rows, :returning)).to eq(1) + end + end + + context 'when scoped to a specific inbox' do + it 'only counts conversations of the requested inbox' do + contact_a = create(:contact, account: account) + contact_b = create(:contact, account: account) + create(:conversation, account: account, inbox: inbox, contact: contact_a, created_at: 1.day.ago) + create(:conversation, account: account, inbox: other_inbox, contact: contact_b, created_at: 1.day.ago) + + rows = build + expect(total_for(rows, :new_leads)).to eq(1) + end + end + + context 'when filtering by period range' do + it 'ignores conversations outside the range' do + contact = create(:contact, account: account) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 60.days.ago) + + rows = build + expect(total_for(rows, :new_leads)).to eq(0) + end + end + + context 'when validating response shape' do + it 'returns rows with iso8601 period and integer counts' do + contact = create(:contact, account: account) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: 1.day.ago) + + rows = build + expect(rows).to all(include(:period, :new_leads, :returning, :others)) + expect(rows.first[:period]).to match(/\d{4}-\d{2}-\d{2}T/) + end + end + end +end