fix(retention): cohort endpoint 500 — Pundit policy + SQL binding

Dois bugs que faziam o cohort retornar 500 e a página de Retenção mostrar
"Falha ao carregar cohort":

1. `Captain::AssistantPolicy` não tinha `cohort?` → Pundit batia em
   NoMethodError no `check_authorization`. Adicionado como leitura pública
   da assistente, igual `show?`/`playground?`.

2. `RetentionCohortService#cohort_activity` chamava `exec_query(sql, name, [@account.id])`
   passando array de valores onde a API espera bind objects. A SQL ainda
   interpolava `account_id = $1` direto na string (sem placeholder ligado).
   Migrado pra `ActiveRecord::Base.sanitize_sql_array` com `?`, igual ao
   resto da base. Mantém parametrização e remove acoplamento com posicional.

Validado em prod via hot-patch (USR2): GET retention/cohort agora 200 com
3 cohorts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-26 12:46:20 -03:00
parent d831ee4d33
commit 21f5fcce6a
2 changed files with 44 additions and 32 deletions

View File

@ -34,4 +34,8 @@ class Captain::AssistantPolicy < ApplicationPolicy
def revenue? def revenue?
true true
end end
def cohort?
true
end
end end

View File

@ -74,7 +74,7 @@ class Captain::Reports::RetentionCohortService
end_month = Time.current.beginning_of_month end_month = Time.current.beginning_of_month
sql = activity_sql(start_month, end_month) sql = activity_sql(start_month, end_month)
rows = ActiveRecord::Base.connection.exec_query(sql, 'RetentionCohort', [@account.id]) rows = ActiveRecord::Base.connection.exec_query(sql, 'RetentionCohort')
hash = Hash.new { |h, k| h[k] = {} } hash = Hash.new { |h, k| h[k] = {} }
@ -89,37 +89,45 @@ class Captain::Reports::RetentionCohortService
# rubocop:disable Metrics/MethodLength # rubocop:disable Metrics/MethodLength
def activity_sql(start_month, end_month) def activity_sql(start_month, end_month)
<<~SQL.squish ActiveRecord::Base.sanitize_sql_array([
WITH contact_cohorts AS ( <<~SQL.squish,
SELECT id AS contact_id, WITH contact_cohorts AS (
DATE_TRUNC('month', first_interaction_at) AS cohort_month SELECT id AS contact_id,
FROM contacts DATE_TRUNC('month', first_interaction_at) AS cohort_month
WHERE first_interaction_at IS NOT NULL FROM contacts
AND first_interaction_at >= '#{start_month.iso8601}' WHERE first_interaction_at IS NOT NULL
AND account_id = $1 AND first_interaction_at >= ?
), AND account_id = ?
contact_activity AS ( ),
SELECT DISTINCT contact_activity AS (
conversations.contact_id, SELECT DISTINCT
DATE_TRUNC('month', messages.created_at) AS activity_month conversations.contact_id,
FROM messages DATE_TRUNC('month', messages.created_at) AS activity_month
INNER JOIN conversations ON conversations.id = messages.conversation_id FROM messages
WHERE messages.sender_type = 'Contact' INNER JOIN conversations ON conversations.id = messages.conversation_id
AND messages.private = false WHERE messages.sender_type = 'Contact'
AND (messages.status IS NULL OR messages.status <> #{failed_status_value}) AND messages.private = false
AND conversations.account_id = $1 AND (messages.status IS NULL OR messages.status <> ?)
AND messages.created_at >= '#{start_month.iso8601}' AND conversations.account_id = ?
AND messages.created_at < '#{(end_month + 1.month).iso8601}' AND messages.created_at >= ?
) AND messages.created_at < ?
SELECT c.cohort_month, )
a.activity_month, SELECT c.cohort_month,
COUNT(DISTINCT c.contact_id) AS active_contacts a.activity_month,
FROM contact_cohorts c COUNT(DISTINCT c.contact_id) AS active_contacts
INNER JOIN contact_activity a ON a.contact_id = c.contact_id FROM contact_cohorts c
WHERE a.activity_month >= c.cohort_month INNER JOIN contact_activity a ON a.contact_id = c.contact_id
GROUP BY c.cohort_month, a.activity_month WHERE a.activity_month >= c.cohort_month
ORDER BY c.cohort_month, a.activity_month GROUP BY c.cohort_month, a.activity_month
SQL ORDER BY c.cohort_month, a.activity_month
SQL
start_month,
@account.id,
failed_status_value,
@account.id,
start_month,
end_month + 1.month
])
end end
# rubocop:enable Metrics/MethodLength # rubocop:enable Metrics/MethodLength