feat(reports): aba "Novas × Retorno" no Inbox Report
Mede por inbox/período: leads novos (1ª conversa do contato em qualquer inbox da rede), retorno (conversa anterior resolved há >24h) e outras (conversa anterior open ou resolved <24h). Categorias somadas batem com o conversations_count nativo do report — bucket "outras" garante o fechamento. - Novo builder V2::Reports::InboxLeadsSummaryBuilder com CTE única - Endpoint GET /api/v2/accounts/:id/reports/inbox_leads_summary - Tabs no InboxReportsShow (Visão Geral | Novas × Retorno) - Componente InboxLeadsReport com 3 metric cards + barras empilhadas - API client + Pinia (state/getters/actions/mutations) - i18n en + pt_BR - RSpec do builder cobrindo classificação e isolamento por inbox Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3336c68ee3
commit
3897db325e
111
app/builders/v2/reports/inbox_leads_summary_builder.rb
Normal file
111
app/builders/v2/reports/inbox_leads_summary_builder.rb
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,26 +1,71 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useFunctionGetter } from 'dashboard/composables/store';
|
||||
|
||||
import WootReports from './components/WootReports.vue';
|
||||
import InboxLeadsReport from './components/InboxLeadsReport.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const inbox = useFunctionGetter('inboxes/getInboxById', route.params.id);
|
||||
|
||||
const TABS = {
|
||||
OVERVIEW: 'overview',
|
||||
LEADS: 'leads',
|
||||
};
|
||||
|
||||
const activeTab = ref(TABS.OVERVIEW);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WootReports
|
||||
v-if="inbox.id"
|
||||
:key="inbox.id"
|
||||
type="inbox"
|
||||
getter-key="inboxes/getInboxes"
|
||||
action-key="inboxes/get"
|
||||
:selected-item="inbox"
|
||||
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
|
||||
:report-title="$t('INBOX_REPORTS.HEADER')"
|
||||
has-back-button
|
||||
/>
|
||||
<div v-if="inbox.id" class="flex flex-col w-full">
|
||||
<div class="flex items-center gap-6 px-6 pt-4 border-b border-n-weak">
|
||||
<button
|
||||
type="button"
|
||||
class="py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="
|
||||
activeTab === TABS.OVERVIEW
|
||||
? 'border-n-brand text-n-brand'
|
||||
: 'border-transparent text-n-slate-11 hover:text-n-slate-12'
|
||||
"
|
||||
@click="activeTab = TABS.OVERVIEW"
|
||||
>
|
||||
{{ $t('INBOX_REPORTS.TABS.OVERVIEW') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="py-3 text-sm font-medium border-b-2 transition-colors"
|
||||
:class="
|
||||
activeTab === TABS.LEADS
|
||||
? 'border-n-brand text-n-brand'
|
||||
: 'border-transparent text-n-slate-11 hover:text-n-slate-12'
|
||||
"
|
||||
@click="activeTab = TABS.LEADS"
|
||||
>
|
||||
{{ $t('INBOX_REPORTS.TABS.LEADS') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4">
|
||||
<WootReports
|
||||
v-if="activeTab === TABS.OVERVIEW"
|
||||
:key="`overview-${inbox.id}`"
|
||||
type="inbox"
|
||||
getter-key="inboxes/getInboxes"
|
||||
action-key="inboxes/get"
|
||||
:selected-item="inbox"
|
||||
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
|
||||
:report-title="$t('INBOX_REPORTS.HEADER')"
|
||||
has-back-button
|
||||
/>
|
||||
<InboxLeadsReport
|
||||
v-else-if="activeTab === TABS.LEADS"
|
||||
:key="`leads-${inbox.id}`"
|
||||
:inbox-id="inbox.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full py-20">
|
||||
<Spinner class="mx-auto" />
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { format } from 'date-fns';
|
||||
import ReportFilters from './ReportFilters.vue';
|
||||
import ReportMetricCard from './ReportMetricCard.vue';
|
||||
import BarChart from 'shared/components/charts/BarChart.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const filters = ref({
|
||||
from: null,
|
||||
to: null,
|
||||
groupBy: { id: 1, period: 'day' },
|
||||
});
|
||||
|
||||
const isFetching = computed(() => store.getters.getInboxLeadsSummaryFetching);
|
||||
const rows = computed(() => store.getters.getInboxLeadsSummary || []);
|
||||
|
||||
const totals = computed(() => {
|
||||
return rows.value.reduce(
|
||||
(acc, row) => {
|
||||
acc.new_leads += row.new_leads || 0;
|
||||
acc.returning += row.returning || 0;
|
||||
acc.others += row.others || 0;
|
||||
return acc;
|
||||
},
|
||||
{ new_leads: 0, returning: 0, others: 0 }
|
||||
);
|
||||
});
|
||||
|
||||
const totalConversations = computed(
|
||||
() => totals.value.new_leads + totals.value.returning + totals.value.others
|
||||
);
|
||||
|
||||
const formatPeriodLabel = (iso, period) => {
|
||||
const date = new Date(iso);
|
||||
if (period === 'month') return format(date, 'MMM/yy');
|
||||
if (period === 'week') return format(date, "'S'II/yy");
|
||||
return format(date, 'dd/MM');
|
||||
};
|
||||
|
||||
const chartCollection = computed(() => {
|
||||
const period = filters.value.groupBy?.period || 'day';
|
||||
return {
|
||||
labels: rows.value.map(r => formatPeriodLabel(r.period, period)),
|
||||
datasets: [
|
||||
{
|
||||
label: t('INBOX_REPORTS.LEADS.CHART.NEW_LEADS'),
|
||||
backgroundColor: '#10B981',
|
||||
data: rows.value.map(r => r.new_leads),
|
||||
},
|
||||
{
|
||||
label: t('INBOX_REPORTS.LEADS.CHART.RETURNING'),
|
||||
backgroundColor: '#3B82F6',
|
||||
data: rows.value.map(r => r.returning),
|
||||
},
|
||||
{
|
||||
label: t('INBOX_REPORTS.LEADS.CHART.OTHERS'),
|
||||
backgroundColor: '#9CA3AF',
|
||||
data: rows.value.map(r => r.others),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const chartOptions = {
|
||||
plugins: {
|
||||
legend: { display: true, position: 'bottom' },
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { drawOnChartArea: false } },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1 } },
|
||||
},
|
||||
};
|
||||
|
||||
const fetchData = () => {
|
||||
if (!filters.value.from || !filters.value.to) return;
|
||||
store.dispatch('fetchInboxLeadsSummary', {
|
||||
inboxId: props.inboxId,
|
||||
from: filters.value.from,
|
||||
to: filters.value.to,
|
||||
groupBy: filters.value.groupBy?.period || 'day',
|
||||
});
|
||||
};
|
||||
|
||||
const onFilterChange = payload => {
|
||||
filters.value = {
|
||||
from: payload.from,
|
||||
to: payload.to,
|
||||
groupBy: payload.groupBy || filters.value.groupBy,
|
||||
};
|
||||
fetchData();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.inboxId,
|
||||
() => fetchData()
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<ReportFilters
|
||||
filter-type="inboxes"
|
||||
:selected-item="{ id: Number(inboxId) }"
|
||||
:show-business-hours="false"
|
||||
:show-entity-filter="false"
|
||||
@filter-change="onFilterChange"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<ReportMetricCard
|
||||
:label="$t('INBOX_REPORTS.LEADS.METRICS.NEW_LEADS.LABEL')"
|
||||
:value="String(totals.new_leads)"
|
||||
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.NEW_LEADS.INFO')"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('INBOX_REPORTS.LEADS.METRICS.RETURNING.LABEL')"
|
||||
:value="String(totals.returning)"
|
||||
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.RETURNING.INFO')"
|
||||
/>
|
||||
<ReportMetricCard
|
||||
:label="$t('INBOX_REPORTS.LEADS.METRICS.OTHERS.LABEL')"
|
||||
:value="String(totals.others)"
|
||||
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.OTHERS.INFO')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-n-solid-1 border border-n-weak rounded-lg p-4 min-h-[320px] flex items-center justify-center"
|
||||
>
|
||||
<Spinner v-if="isFetching" />
|
||||
<div v-else-if="rows.length === 0" class="text-sm text-n-slate-11">
|
||||
{{ $t('INBOX_REPORTS.LEADS.EMPTY') }}
|
||||
</div>
|
||||
<div v-else class="w-full h-[320px]">
|
||||
<BarChart :collection="chartCollection" :chart-options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ $t('INBOX_REPORTS.LEADS.TOTAL', { count: totalConversations }) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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]
|
||||
|
||||
141
spec/builders/v2/reports/inbox_leads_summary_builder_spec.rb
Normal file
141
spec/builders/v2/reports/inbox_leads_summary_builder_spec.rb
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user