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 @@