iachat/db/migrate/20260422094015_add_retention_stats_to_contacts.rb
Rodribm10 f6488ce2de feat(retention): foundation for customer retention metrics
Lays the data + job foundation for tracking customer interactions,
recurrence, and Pix conversion on Contact. Design decisions negotiated
with Rodrigo (see docs to come):

Rules:
- Gap of 30h from last message defines separate interactions
- Qualified interaction = >=2 customer msgs + >=2 attendant msgs,
  both with textual content (>= 2 letters)
- One-shot consultation = >=1+1 but below the qualified threshold
  (tracked as secondary KPI)
- Excludes contacts labeled `equipe_interna`
- is_recurring = interactions_count >= 2
- pix_generated_count counts all PixCharges; reservations_paid_count
  only counts those with status = paid

Surface area:
- Migration adds denormalized stats to contacts + indexes for fast filtering
- Captain::ContactStats::InteractionCalculatorService computes the stats
  for a single contact (pure, no persistence)
- Captain::Retention::RecalculateContactStatsJob persists them for one
  contact (idempotent)
- Captain::Retention::RecalculateAllContactStatsJob runs daily at 3am BRT,
  enqueues per-contact jobs for everyone active in the last 120 days
- Event-driven refresh: CaptainListener#conversation_resolved enqueues
  recalc; Captain::PixCharge after_create/after_update enqueues recalc
  on status change

No UI yet — that's the next layer.
2026-04-22 09:50:23 -03:00

30 lines
1.7 KiB
Ruby

class AddRetentionStatsToContacts < ActiveRecord::Migration[7.1]
# Desnormaliza estatísticas de retenção/recorrência no próprio contato.
# Atualizado por Captain::ContactStats::RecalculateJob (diário) + hooks
# incrementais quando conversa ou PixCharge muda de estado.
#
# Colunas:
# - interactions_count: interações qualificadas (≥2 msg cliente + ≥2 msg Jasmine, gap 30h)
# - one_shot_consultations_count: consultas ≥1+1 que não atingiram o limiar de qualificada
# - first_interaction_at / last_interaction_at: range da presença do cliente
# - pix_generated_count: quantos Pix foram gerados (sinal de intenção)
# - reservations_paid_count: quantos Pix foram efetivamente pagos (reserva real)
# - is_recurring: true se interactions_count >= 2
# - days_since_last_interaction: materializado pra filtros rápidos sem funções em tempo real
def change
add_column :contacts, :interactions_count, :integer, default: 0, null: false
add_column :contacts, :one_shot_consultations_count, :integer, default: 0, null: false
add_column :contacts, :first_interaction_at, :datetime
add_column :contacts, :last_interaction_at, :datetime
add_column :contacts, :pix_generated_count, :integer, default: 0, null: false
add_column :contacts, :reservations_paid_count, :integer, default: 0, null: false
add_column :contacts, :is_recurring, :boolean, default: false, null: false
add_column :contacts, :days_since_last_interaction, :integer
add_index :contacts, :last_interaction_at
add_index :contacts, :is_recurring
add_index :contacts, :days_since_last_interaction
add_index :contacts, %i[account_id is_recurring last_interaction_at], name: 'idx_contacts_account_recurring_last'
end
end