feat(retention): summary KPIs + cohort endpoints

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.
This commit is contained in:
Rodribm10 2026-04-22 09:59:21 -03:00
parent f6488ce2de
commit aed6d62640
4 changed files with 290 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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