From aed6d626408138949d409feb641ddf20bb06c981 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Wed, 22 Apr 2026 09:59:21 -0300 Subject: [PATCH] feat(retention): summary KPIs + cohort endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes two JSON endpoints under /api/v1/accounts/:id/captain/reports: - GET /retention — aggregate KPIs (active/recurring/sleeping/at-risk/ churned, new vs returned in period, Pix generated/paid/conversion, retention rates at 30d and 90d) - GET /retention/cohort — monthly cohort matrix, 12 months lookback, 12 months of offset. Each cell is % of the cohort that interacted in month M+N. SQL-aggregated with DATE_TRUNC + DISTINCT so it is a single query even on large histories. --- .../captain/reports/retention_controller.rb | 29 ++++ config/routes.rb | 3 + .../reports/retention_cohort_service.rb | 130 ++++++++++++++++++ .../reports/retention_summary_service.rb | 128 +++++++++++++++++ 4 files changed, 290 insertions(+) create mode 100644 app/controllers/api/v1/accounts/captain/reports/retention_controller.rb create mode 100644 enterprise/app/services/captain/reports/retention_cohort_service.rb create mode 100644 enterprise/app/services/captain/reports/retention_summary_service.rb diff --git a/app/controllers/api/v1/accounts/captain/reports/retention_controller.rb b/app/controllers/api/v1/accounts/captain/reports/retention_controller.rb new file mode 100644 index 000000000..3d9117163 --- /dev/null +++ b/app/controllers/api/v1/accounts/captain/reports/retention_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Endpoints de retenção e recorrência pra Relatórios > Retenção. +# GET /api/v1/accounts/:account_id/captain/reports/retention → summary (KPIs) +# GET /api/v1/accounts/:account_id/captain/reports/retention/cohort → matriz cohort +class Api::V1::Accounts::Captain::Reports::RetentionController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::Assistant) } + + def show + summary = Captain::Reports::RetentionSummaryService.new( + account: current_account, + period_start: params[:start_date], + period_end: params[:end_date] + ).call + + render json: summary + end + + def cohort + months = params[:history_months].presence&.to_i + data = Captain::Reports::RetentionCohortService.new( + account: current_account, + history_months: months || Captain::Reports::RetentionCohortService::DEFAULT_HISTORY_MONTHS + ).call + + render json: data + end +end diff --git a/config/routes.rb b/config/routes.rb index 76aafd3c7..63117679f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -113,6 +113,9 @@ Rails.application.routes.draw do post :generate, on: :collection end resource :funnel, only: [:show], controller: :funnel + resource :retention, only: [:show], controller: :retention do + get :cohort + end end # Roleta da Sorte - tela de resgate na recepção + relatório semanal resource :roleta, only: [], controller: 'roleta' do diff --git a/enterprise/app/services/captain/reports/retention_cohort_service.rb b/enterprise/app/services/captain/reports/retention_cohort_service.rb new file mode 100644 index 000000000..2eafb7102 --- /dev/null +++ b/enterprise/app/services/captain/reports/retention_cohort_service.rb @@ -0,0 +1,130 @@ +# Gera a matriz de cohort mensal de retenção por interação. +# +# Linha = cohort (mês em que o contato teve a primeira interação). +# Coluna = offset em meses (M+0, M+1, M+2, ..., M+12). +# Célula = % de contatos daquela cohort que tiveram QUALQUER mensagem +# no mês M+N. +# +# Granularidade mensal dispensa o gap de 30h do InteractionCalculator: +# se o contato teve qualquer msg em um mês, conta como retido naquele mês. +# A imprecisão de dedup por hora fica embutida dentro do bucket mensal. +# +# O cálculo é uma agregação SQL só, sem varrer contato a contato. +class Captain::Reports::RetentionCohortService + DEFAULT_HISTORY_MONTHS = 12 + MAX_OFFSET_MONTHS = 12 + + def initialize(account:, history_months: DEFAULT_HISTORY_MONTHS) + @account = account + @history_months = history_months.to_i.clamp(1, 24) + end + + def call + sizes = cohort_sizes + activity = cohort_activity(sizes.keys) + + { + generated_at: Time.current.iso8601, + history_months: @history_months, + max_offset_months: MAX_OFFSET_MONTHS, + cohorts: build_cohort_rows(sizes, activity) + } + end + + private + + def build_cohort_rows(sizes, activity) + sizes.sort.reverse.map do |cohort_month, size| + { + cohort: cohort_month.iso8601, + size: size, + retention: build_retention_row(cohort_month, size, activity) + } + end + end + + def build_retention_row(cohort_month, size, activity) + (0..MAX_OFFSET_MONTHS).map do |offset| + active = activity.dig(cohort_month, cohort_month >> offset) || 0 + rate = size.zero? ? 0.0 : (active.to_f / size).round(4) + { month_offset: offset, count: active, rate: rate } + end + end + + # Retorna { Date => Integer }: chave é o 1º dia do mês da cohort, valor é + # a quantidade de contatos na cohort. + def cohort_sizes + start_month = (Time.current - @history_months.months).beginning_of_month + + rows = @account.contacts + .where.not(first_interaction_at: nil) + .where('first_interaction_at >= ?', start_month) + .group(Arel.sql("DATE_TRUNC('month', first_interaction_at)")) + .count + + rows.transform_keys { |ts| ts.to_date.beginning_of_month } + end + + # Retorna { cohort_month => { activity_month => count } } + # Monta só os cohorts passados e considera atividade até hoje. + def cohort_activity(cohort_months) + return {} if cohort_months.empty? + + start_month = cohort_months.min + end_month = Time.current.beginning_of_month + + sql = activity_sql(start_month, end_month) + rows = ActiveRecord::Base.connection.exec_query(sql, 'RetentionCohort', [@account.id]) + + hash = Hash.new { |h, k| h[k] = {} } + + rows.each do |row| + cohort_month = row['cohort_month'].to_date.beginning_of_month + activity_month = row['activity_month'].to_date.beginning_of_month + hash[cohort_month][activity_month] = row['active_contacts'].to_i + end + + hash + end + + # rubocop:disable Metrics/MethodLength + def activity_sql(start_month, end_month) + <<~SQL.squish + WITH contact_cohorts AS ( + SELECT id AS contact_id, + DATE_TRUNC('month', first_interaction_at) AS cohort_month + FROM contacts + WHERE first_interaction_at IS NOT NULL + AND first_interaction_at >= '#{start_month.iso8601}' + AND account_id = $1 + ), + contact_activity AS ( + SELECT DISTINCT + conversations.contact_id, + DATE_TRUNC('month', messages.created_at) AS activity_month + FROM messages + INNER JOIN conversations ON conversations.id = messages.conversation_id + WHERE messages.sender_type = 'Contact' + AND messages.private = false + AND (messages.status IS NULL OR messages.status <> #{failed_status_value}) + AND conversations.account_id = $1 + AND messages.created_at >= '#{start_month.iso8601}' + AND messages.created_at < '#{(end_month + 1.month).iso8601}' + ) + SELECT c.cohort_month, + a.activity_month, + COUNT(DISTINCT c.contact_id) AS active_contacts + FROM contact_cohorts c + INNER JOIN contact_activity a ON a.contact_id = c.contact_id + WHERE a.activity_month >= c.cohort_month + GROUP BY c.cohort_month, a.activity_month + ORDER BY c.cohort_month, a.activity_month + SQL + end + # rubocop:enable Metrics/MethodLength + + # Message.statuses['failed'] retorna o integer do enum. + def failed_status_value + Message.statuses['failed'] + end +end diff --git a/enterprise/app/services/captain/reports/retention_summary_service.rb b/enterprise/app/services/captain/reports/retention_summary_service.rb new file mode 100644 index 000000000..6c4e36b78 --- /dev/null +++ b/enterprise/app/services/captain/reports/retention_summary_service.rb @@ -0,0 +1,128 @@ +# Calcula KPIs agregados de retenção e recorrência para uma account. +# Funciona sobre as colunas desnormalizadas em `contacts` mantidas por +# Captain::Retention::RecalculateContactStatsJob — stats a partir das colunas +# são O(1) por KPI (count + where), sem precisar varrer mensagens. +# +# Janela temporal do período: [period_start, period_end]. Default: mês corrente. +# Cortes de status absolutos (ativo/dormindo/etc) sempre relativos a Time.current, +# independentemente do período — o período afeta apenas as métricas de fluxo +# ("novos no período", "retorno no período"). +class Captain::Reports::RetentionSummaryService + SLEEPING_WINDOW = (30.days)..(90.days) + AT_RISK_WINDOW = (90.days)..(180.days) + RETENTION_30D_WINDOW = 30.days + RETENTION_90D_WINDOW = 90.days + + def initialize(account:, period_start: nil, period_end: nil) + @account = account + @period_start = period_start&.to_date || Time.current.beginning_of_month.to_date + @period_end = period_end&.to_date || Time.current.to_date + end + + def call + { + period: { + start: @period_start.iso8601, + end: @period_end.iso8601 + }, + status: status_counts, + flow: flow_counts, + pix: pix_counts, + retention: retention_rates + } + end + + private + + def contacts + @contacts ||= @account.contacts + end + + # Cortes de status pelo estado atual (Time.current) + # rubocop:disable Metrics/AbcSize + def status_counts + now = Time.current + + active = contacts.where('last_interaction_at >= ?', now - 30.days).count + recurring = contacts.where(is_recurring: true) + .where('last_interaction_at >= ?', now - 90.days).count + sleeping = contacts.where(last_interaction_at: (now - SLEEPING_WINDOW.max)..(now - SLEEPING_WINDOW.min)).count + at_risk = contacts.where(last_interaction_at: (now - AT_RISK_WINDOW.max)..(now - AT_RISK_WINDOW.min)).count + churned = contacts.where('last_interaction_at < ?', now - 180.days).count + never_interacted = contacts.where(last_interaction_at: nil).count + + { + active: active, + recurring: recurring, + sleeping: sleeping, + at_risk: at_risk, + churned: churned, + never_interacted: never_interacted + } + end + # rubocop:enable Metrics/AbcSize + + # Fluxo dentro do período + def flow_counts + period_range = @period_start.beginning_of_day..@period_end.end_of_day + + new_in_period = contacts.where(first_interaction_at: period_range).count + returned_in_period = contacts + .where('first_interaction_at < ?', period_range.begin) + .where(last_interaction_at: period_range) + .count + + { + new: new_in_period, + returned: returned_in_period, + total_touches: new_in_period + returned_in_period + } + end + + # Pix gerado e pago no período (globais, baseado em PixCharge) + def pix_counts + period_range = @period_start.beginning_of_day..@period_end.end_of_day + + generated = Captain::PixCharge.where(created_at: period_range).where(unit_id: account_unit_ids).count + paid = Captain::PixCharge.where(paid_at: period_range).where(unit_id: account_unit_ids).count + + conversion = generated.zero? ? 0.0 : (paid.to_f / generated).round(4) + + { + generated: generated, + paid: paid, + conversion_rate: conversion + } + end + + def account_unit_ids + @account_unit_ids ||= Captain::Unit.where(account_id: @account.id).pluck(:id) + end + + # Taxa de retorno: dos contatos cuja PRIMEIRA interação foi há {N} dias atrás + # (ex: [N-step, N]), quantos voltaram a interagir nos últimos {step} dias. + # Para 30d: cohort da semana entre 30-37d atrás, retorno nos últimos 7d. + def retention_rates + { + last_30d: retention_rate(RETENTION_30D_WINDOW, 7.days), + last_90d: retention_rate(RETENTION_90D_WINDOW, 14.days) + } + end + + def retention_rate(window, step) + now = Time.current + cohort_start = now - window - step + cohort_end = now - window + + cohort = contacts + .where(first_interaction_at: cohort_start..cohort_end) + cohort_size = cohort.count + return 0.0 if cohort_size.zero? + + returned = cohort.where('last_interaction_at > first_interaction_at + interval \'1 day\'') + .where('last_interaction_at >= ?', now - step) + .count + + (returned.to_f / cohort_size).round(4) + end +end