diff --git a/app/javascript/dashboard/api/captain/reports.js b/app/javascript/dashboard/api/captain/reports.js index 435aa7c3b..e49755b0e 100644 --- a/app/javascript/dashboard/api/captain/reports.js +++ b/app/javascript/dashboard/api/captain/reports.js @@ -33,6 +33,14 @@ class CaptainReportsAPI extends ApiClient { deliverExecutive(params = {}) { return axios.post(`${this.url}/executive/deliver`, params); } + + getRetention(params = {}) { + return axios.get(`${this.url}/retention`, { params }); + } + + getRetentionCohort(params = {}) { + return axios.get(`${this.url}/retention/cohort`, { params }); + } } export default new CaptainReportsAPI(); diff --git a/app/javascript/dashboard/components-next/filter/contactProvider.js b/app/javascript/dashboard/components-next/filter/contactProvider.js index a39000817..6c03ef10f 100644 --- a/app/javascript/dashboard/components-next/filter/contactProvider.js +++ b/app/javascript/dashboard/components-next/filter/contactProvider.js @@ -199,6 +199,61 @@ export function useContactFilterContext() { filterOperators: equalityOperators.value, attributeModel: 'standard', }, + // --- Retenção / recorrência --- + { + attributeKey: CONTACT_ATTRIBUTES.IS_RECURRING, + value: CONTACT_ATTRIBUTES.IS_RECURRING, + attributeName: 'Cliente recorrente', + label: 'Cliente recorrente', + inputType: 'searchSelect', + options: [ + { id: 'true', name: 'Sim' }, + { id: 'false', name: 'Não' }, + ], + dataType: 'text', + filterOperators: equalityOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: CONTACT_ATTRIBUTES.LAST_INTERACTION_AT, + value: CONTACT_ATTRIBUTES.LAST_INTERACTION_AT, + attributeName: 'Última interação', + label: 'Última interação', + inputType: 'date', + dataType: 'text', + filterOperators: dateOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: CONTACT_ATTRIBUTES.DAYS_SINCE_LAST_INTERACTION, + value: CONTACT_ATTRIBUTES.DAYS_SINCE_LAST_INTERACTION, + attributeName: 'Dias sem interagir', + label: 'Dias sem interagir', + inputType: 'plainText', + dataType: 'number', + filterOperators: equalityOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: CONTACT_ATTRIBUTES.INTERACTIONS_COUNT, + value: CONTACT_ATTRIBUTES.INTERACTIONS_COUNT, + attributeName: 'Nº de interações', + label: 'Nº de interações', + inputType: 'plainText', + dataType: 'number', + filterOperators: equalityOperators.value, + attributeModel: 'standard', + }, + { + attributeKey: CONTACT_ATTRIBUTES.RESERVATIONS_PAID_COUNT, + value: CONTACT_ATTRIBUTES.RESERVATIONS_PAID_COUNT, + attributeName: 'Reservas pagas', + label: 'Reservas pagas', + inputType: 'plainText', + dataType: 'number', + filterOperators: equalityOperators.value, + attributeModel: 'standard', + }, ...customFilterTypes.value, ]); diff --git a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js index 274eecb49..d1059b669 100644 --- a/app/javascript/dashboard/components-next/filter/helper/filterHelper.js +++ b/app/javascript/dashboard/components-next/filter/helper/filterHelper.js @@ -28,6 +28,12 @@ export const CONTACT_ATTRIBUTES = { REFERER: 'referer', BLOCKED: 'blocked', LABELS: 'labels', + // Retenção / recorrência (stats desnormalizados em contacts) + IS_RECURRING: 'is_recurring', + LAST_INTERACTION_AT: 'last_interaction_at', + DAYS_SINCE_LAST_INTERACTION: 'days_since_last_interaction', + INTERACTIONS_COUNT: 'interactions_count', + RESERVATIONS_PAID_COUNT: 'reservations_paid_count', }; /** diff --git a/app/javascript/dashboard/components/widgets/conversation/RetentionSummaryBadge.vue b/app/javascript/dashboard/components/widgets/conversation/RetentionSummaryBadge.vue new file mode 100644 index 000000000..2b97dae5e --- /dev/null +++ b/app/javascript/dashboard/components/widgets/conversation/RetentionSummaryBadge.vue @@ -0,0 +1,130 @@ + + + diff --git a/app/javascript/dashboard/i18n/locale/en/captain.json b/app/javascript/dashboard/i18n/locale/en/captain.json index 743b1b585..ccb25ce3c 100644 --- a/app/javascript/dashboard/i18n/locale/en/captain.json +++ b/app/javascript/dashboard/i18n/locale/en/captain.json @@ -531,7 +531,74 @@ "INSIGHTS": "AI Insights", "OPERATIONAL": "Operational", "EXECUTIVE": "Executive", - "LANDING_PAGES": "Landing Pages" + "LANDING_PAGES": "Landing Pages", + "RETENTION": "Retention" + }, + "RETENTION": { + "PERIOD_LABEL": "Period", + "PERIOD_THIS_MONTH": "This month", + "PERIOD_LAST_30": "Last 30 days", + "PERIOD_LAST_90": "Last 90 days", + "PERIOD_CUSTOM": "Custom", + "APPLY": "Apply", + "NO_DATA": "No data.", + "KPI": { + "ACTIVE": "Active customers", + "ACTIVE_HINT": "last interaction within 30 days", + "RECURRING": "Recurring", + "RECURRING_HINT": "≥2 qualified interactions in 90 days", + "RETURN_30D": "30-day return rate", + "RETURN_30D_HINT": "returned to interact within 7 days", + "PIX_CONVERSION": "Pix conversion", + "PIX_CONVERSION_HINT": "{paid} paid out of {generated} generated" + }, + "FLOW": { + "TITLE": "Period flow", + "NEW_IN_PERIOD": "new in period", + "RETURNED_IN_PERIOD": "returned in period", + "TOTAL_TOUCHES": "total interactions", + "BASE_STATUS": "Current base status", + "SLEEPING": "{count} sleeping", + "SLEEPING_HINT": "30-90d without contact", + "AT_RISK": "{count} at risk", + "AT_RISK_HINT": "90-180d without contact", + "CHURNED": "{count} inactive", + "CHURNED_HINT": "180d+ without contact" + }, + "COHORT": { + "TITLE": "Cohort matrix", + "SUBTITLE": "% of each cohort's customers who returned to interact in M+N months.", + "EXPORT_CSV": "Export CSV", + "EMPTY": "No cohort data yet.", + "COL_COHORT": "Cohort", + "COL_SIZE": "Size", + "CELL_TITLE": "{count} active contacts ({rate}%)" + }, + "BADGE": { + "STATUS_FIRST": "First contact", + "STATUS_INACTIVE": "Inactive", + "STATUS_AT_RISK": "At risk", + "STATUS_SLEEPING": "Sleeping", + "STATUS_RECURRING": "Recurring", + "STATUS_ACTIVE": "Active", + "LAST_INTERACTION": "last {days}", + "INTERACTIONS_LABEL": "interaction | interactions", + "INTERACTIONS_TITLE": "Qualified interactions (≥2+2 messages)", + "ONE_SHOT_LABEL": "one-shot", + "ONE_SHOT_TITLE": "One-shot consultations (≥1+1)", + "PIX_LABEL": "Pix paid", + "PIX_TITLE": "Pix generated / reservations paid", + "DAYS_TODAY": "today", + "DAYS_YESTERDAY": "yesterday", + "DAYS_RECENT": "{days} days ago", + "DAYS_ONE_MONTH": "about 1 month ago", + "DAYS_MONTHS": "{months} months ago", + "DAYS_YEARS": "{years} years ago" + }, + "ERRORS": { + "SUMMARY": "Failed to load retention KPIs", + "COHORT": "Failed to load cohort" + } }, "EXECUTIVE": { "LOADING": "Loading executive digest...", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/captain.json b/app/javascript/dashboard/i18n/locale/pt_BR/captain.json index 55c1ee0ae..c7f291426 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/captain.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/captain.json @@ -533,7 +533,74 @@ "INSIGHTS": "Insights IA", "OPERATIONAL": "Operacional", "EXECUTIVE": "Executivo", - "LANDING_PAGES": "Landing Pages" + "LANDING_PAGES": "Landing Pages", + "RETENTION": "Retenção" + }, + "RETENTION": { + "PERIOD_LABEL": "Período", + "PERIOD_THIS_MONTH": "Este mês", + "PERIOD_LAST_30": "Últimos 30 dias", + "PERIOD_LAST_90": "Últimos 90 dias", + "PERIOD_CUSTOM": "Personalizado", + "APPLY": "Aplicar", + "NO_DATA": "Sem dados.", + "KPI": { + "ACTIVE": "Clientes ativos", + "ACTIVE_HINT": "última interação nos últimos 30 dias", + "RECURRING": "Recorrentes", + "RECURRING_HINT": "≥2 interações qualificadas em 90 dias", + "RETURN_30D": "Taxa de retorno 30d", + "RETURN_30D_HINT": "voltaram a interagir em 7 dias", + "PIX_CONVERSION": "Conversão Pix", + "PIX_CONVERSION_HINT": "{paid} pagos de {generated} gerados" + }, + "FLOW": { + "TITLE": "Fluxo do período", + "NEW_IN_PERIOD": "novos no período", + "RETURNED_IN_PERIOD": "retornaram no período", + "TOTAL_TOUCHES": "interações totais", + "BASE_STATUS": "Situação atual da base", + "SLEEPING": "{count} adormecidos", + "SLEEPING_HINT": "30-90d sem contato", + "AT_RISK": "{count} em risco", + "AT_RISK_HINT": "90-180d sem contato", + "CHURNED": "{count} inativos", + "CHURNED_HINT": "180d+ sem contato" + }, + "COHORT": { + "TITLE": "Matriz de cohort", + "SUBTITLE": "% de clientes de cada cohort que voltaram a interagir em M+N meses.", + "EXPORT_CSV": "Exportar CSV", + "EMPTY": "Ainda não há cohorts com dados.", + "COL_COHORT": "Cohort", + "COL_SIZE": "Tamanho", + "CELL_TITLE": "{count} contatos ativos ({rate}%)" + }, + "BADGE": { + "STATUS_FIRST": "Primeiro contato", + "STATUS_INACTIVE": "Inativo", + "STATUS_AT_RISK": "Em risco", + "STATUS_SLEEPING": "Adormecido", + "STATUS_RECURRING": "Recorrente", + "STATUS_ACTIVE": "Ativo", + "LAST_INTERACTION": "última {days}", + "INTERACTIONS_LABEL": "interação | interações", + "INTERACTIONS_TITLE": "Interações qualificadas (≥2+2 mensagens)", + "ONE_SHOT_LABEL": "one-shot", + "ONE_SHOT_TITLE": "Consultas one-shot (≥1+1)", + "PIX_LABEL": "Pix pagos", + "PIX_TITLE": "Pix gerados / reservas pagas", + "DAYS_TODAY": "hoje", + "DAYS_YESTERDAY": "ontem", + "DAYS_RECENT": "há {days} dias", + "DAYS_ONE_MONTH": "há cerca de 1 mês", + "DAYS_MONTHS": "há {months} meses", + "DAYS_YEARS": "há {years} anos" + }, + "ERRORS": { + "SUMMARY": "Falha ao carregar KPIs de retenção", + "COHORT": "Falha ao carregar cohort" + } }, "EXECUTIVE": { "LOADING": "Carregando digest executivo...", diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue index 734950f69..71105b540 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue @@ -1,11 +1,13 @@ diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/CohortMatrix.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/CohortMatrix.vue new file mode 100644 index 000000000..1f16971e1 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/CohortMatrix.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/FlowCard.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/FlowCard.vue new file mode 100644 index 000000000..786043518 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/FlowCard.vue @@ -0,0 +1,133 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/KpiCards.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/KpiCards.vue new file mode 100644 index 000000000..73d6fbcdf --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/KpiCards.vue @@ -0,0 +1,105 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/RetentionTab.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/RetentionTab.vue new file mode 100644 index 000000000..43b34cf21 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/retention/RetentionTab.vue @@ -0,0 +1,135 @@ + + + diff --git a/docs/captain-retention-indicators.md b/docs/captain-retention-indicators.md new file mode 100644 index 000000000..b241fe1ad --- /dev/null +++ b/docs/captain-retention-indicators.md @@ -0,0 +1,138 @@ +# Captain — Indicadores de Retenção e Recorrência + +Design e implementação do sistema de métricas de retenção para motel (negociado +com Rodrigo em 2026-04-22). + +## Regras de negócio + +### Definição de "interação" +- Todas as mensagens públicas (não-privadas, status != failed) do contato e do + atendimento (Captain::Assistant ou User) são consideradas. +- **Gap de 30h** entre a última msg de uma interação e a próxima msg abre uma + nova interação. Abaixo disso, mesma "sessão". +- Motivo: motel tem frequência alta (múltiplas visitas na mesma semana); 30h + evita que manhã+tarde virem 2 interações mas captura corretamente + "voltou-no-dia-seguinte". + +### Classificação de cada grupo +| Cliente | Atendimento | Tipo | +|---------|-------------|------| +| ≥ 2 msgs textuais | ≥ 2 msgs textuais | **qualificada** (conta em `interactions_count`) | +| ≥ 1 msg textual | ≥ 1 msg textual | **one-shot** (conta em `one_shot_consultations_count`) | +| 0 de um dos lados | — | ignorado | + +**Textual** = pelo menos 2 letras (`\p{L}`). Exclui emoji-only, sticker, +"Message without content". + +### Exclusão +Contatos com o label `equipe_interna` (manual) são ignorados em todas as +métricas. Aplica-se a gerentes, testes, números internos. + +### Recorrência +`is_recurring = interactions_count >= 2`. + +### Reservas vs Pix +- `pix_generated_count`: toda `Captain::PixCharge` criada (sinal de intenção). +- `reservations_paid_count`: `Captain::PixCharge.where(status: 'paid')` (reserva real). +- Conversão = paid / generated. + +## Colunas em `contacts` + +| Coluna | Tipo | Atualizada por | +|--------|------|----------------| +| `interactions_count` | integer | `Captain::Retention::RecalculateContactStatsJob` | +| `one_shot_consultations_count` | integer | idem | +| `first_interaction_at` | datetime | idem | +| `last_interaction_at` | datetime | idem | +| `pix_generated_count` | integer | idem (conta PixCharges) | +| `reservations_paid_count` | integer | idem | +| `is_recurring` | boolean | idem | +| `days_since_last_interaction` | integer | idem (recalculado no job) | + +## Fluxo de atualização + +- **Job diário** (`captain_retention_recalculate_all_contact_stats_job`, 6h UTC + / 3h BRT): itera todos os contatos com conversa nos últimos 120 dias e + enfileira `RecalculateContactStatsJob` por contact. +- **Evento conversa resolvida**: `CaptainListener#conversation_resolved` + enfileira recalc incremental do contato. +- **Evento PixCharge**: `after_create_commit` e `after_update_commit` + enfileiram recalc (cobre geração e mudança pra paid/expired/failed). + +## Endpoints + +### `GET /api/v1/accounts/:id/captain/reports/retention` +Params: `start_date`, `end_date` (ISO, opcional, default = mês corrente). + +Resposta: +```json +{ + "period": { "start": "2026-04-01", "end": "2026-04-22" }, + "status": { + "active": 340, "recurring": 85, "sleeping": 62, + "at_risk": 30, "churned": 15, "never_interacted": 0 + }, + "flow": { "new": 120, "returned": 40, "total_touches": 160 }, + "pix": { "generated": 180, "paid": 45, "conversion_rate": 0.25 }, + "retention": { "last_30d": 0.28, "last_90d": 0.12 } +} +``` + +Cortes de status (relativos a `Time.current`): +- **active**: `last_interaction_at >= now - 30d` +- **recurring**: `is_recurring=true AND last_interaction_at >= now - 90d` +- **sleeping**: `last_interaction_at` entre 30 e 90 dias atrás +- **at_risk**: entre 90 e 180 dias atrás +- **churned**: mais de 180 dias atrás +- **never_interacted**: `last_interaction_at IS NULL` + +### `GET /api/v1/accounts/:id/captain/reports/retention/cohort` +Params: `history_months` (default 12, max 24). + +Resposta: matriz com `cohorts` (linha por mês), cada linha tem +`size` + `retention[]` (array de 13 objetos: offset 0..12 com count e rate). + +Cohort = mês da `first_interaction_at`. Célula [cohort, M+N] = % de contatos +da cohort com qualquer msg de cliente no mês M+N. + +## UI + +- **Aba Retenção em Relatórios do Captain** (`retention/RetentionTab.vue`): + - KpiCards: 4 cards (ativos, recorrentes, retorno 30d, conversão Pix) + - FlowCard: novos vs retornaram no período + situação absoluta (sleeping/ + at_risk/churned) + - CohortMatrix: tabela cohort 12x13 com heatmap verde + export CSV +- **Badge no card de contato** (`RetentionSummaryBadge.vue`): mostra tier + (Primeiro contato/Ativo/Recorrente/Adormecido/Em risco/Inativo) + contadores + + "última há X dias". +- **Filtros na lista de contatos** (`contactProvider.js`): 5 filtros novos — + cliente recorrente, última interação, dias sem interagir, nº de interações, + reservas pagas. + +## Queries SQL de backup + +Contagem rápida de ativos no mês: +```sql +SELECT COUNT(*) FROM contacts +WHERE account_id = $1 AND last_interaction_at >= NOW() - INTERVAL '30 days'; +``` + +Taxa de retorno 30d da cohort de 30-37 dias atrás: +```sql +WITH cohort AS ( + SELECT id FROM contacts + WHERE account_id = $1 + AND first_interaction_at BETWEEN NOW() - INTERVAL '37 days' AND NOW() - INTERVAL '30 days' +) +SELECT (COUNT(*) FILTER (WHERE c.last_interaction_at >= NOW() - INTERVAL '7 days'))::float + / NULLIF(COUNT(*), 0) AS rate +FROM contacts c WHERE c.id IN (SELECT id FROM cohort); +``` + +## Próximos passos possíveis + +- Recalcular `first_interaction_at` / `last_interaction_at` via SQL direto + no job (atualmente é via service Ruby — OK até ~10k contatos ativos). +- Widget "Top recorrentes" (ranking dos contatos com mais reservas pagas). +- Alerta quando taxa de retorno cai > 10% mês a mês. +- Histórico da métrica para rodar tendências (tabela `retention_snapshots`). diff --git a/spec/enterprise/services/captain/contact_stats/interaction_calculator_service_spec.rb b/spec/enterprise/services/captain/contact_stats/interaction_calculator_service_spec.rb new file mode 100644 index 000000000..69d6bcc71 --- /dev/null +++ b/spec/enterprise/services/captain/contact_stats/interaction_calculator_service_spec.rb @@ -0,0 +1,114 @@ +require 'rails_helper' + +RSpec.describe Captain::ContactStats::InteractionCalculatorService do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + + def open_conversation(created_at: Time.current) + create(:conversation, account: account, inbox: inbox, contact: contact, created_at: created_at) + end + + def msg(conversation, kind, content, at) + attrs = { conversation: conversation, content: content, created_at: at } + case kind + when :customer + create(:message, message_type: :incoming, sender: contact, **attrs) + when :bot + # Agent/user outgoing — qualifica como "Jasmine" pelo nosso critério + user = create(:user, account: account) + create(:message, message_type: :outgoing, sender: user, **attrs) + when :system + create(:message, message_type: :outgoing, sender: nil, **attrs) + end + end + + describe '#call' do + it 'returns empty stats when there are no conversations' do + expect(described_class.new(contact: contact).call).to include( + interactions_count: 0, one_shot_consultations_count: 0, is_recurring: false + ) + end + + it 'returns empty stats when contact is labelled equipe_interna' do + contact.update!(label_list: 'equipe_interna') + conv = open_conversation + now = Time.current + msg(conv, :customer, 'Oi bom dia', now) + msg(conv, :bot, 'Oi, em que posso ajudar?', now + 1.minute) + + expect(described_class.new(contact: contact).call).to include(interactions_count: 0) + end + + it 'counts a qualified interaction when there are >=2 msgs per side' do + conv = open_conversation + t = Time.current + msg(conv, :customer, 'Oi bom dia', t) + msg(conv, :bot, 'Oi! Posso ajudar?', t + 1.minute) + msg(conv, :customer, 'Qual preço da Stilo?', t + 2.minutes) + msg(conv, :bot, 'Stilo pernoite R$ 140', t + 3.minutes) + + result = described_class.new(contact: contact).call + expect(result[:interactions_count]).to eq(1) + expect(result[:one_shot_consultations_count]).to eq(0) + expect(result[:is_recurring]).to be(false) + end + + it 'classifies 1+1 as one-shot consultation' do + conv = open_conversation + t = Time.current + msg(conv, :customer, 'Quanto custa Alexa?', t) + msg(conv, :bot, 'R$ 100 por 4hrs', t + 1.minute) + + result = described_class.new(contact: contact).call + expect(result[:interactions_count]).to eq(0) + expect(result[:one_shot_consultations_count]).to eq(1) + end + + it 'ignores interactions where one side has no textual content' do + conv = open_conversation + t = Time.current + msg(conv, :customer, '👍', t) # emoji, não conta + msg(conv, :customer, 'Oi', t + 5.minutes) # < 2 letras → não conta + msg(conv, :bot, 'R$ 100', t + 10.minutes) # só dígitos, não conta + + result = described_class.new(contact: contact).call + expect(result[:interactions_count]).to eq(0) + expect(result[:one_shot_consultations_count]).to eq(0) + end + + it 'groups msgs within 30h into the same interaction' do + conv = open_conversation + t = 2.days.ago + + msg(conv, :customer, 'Oi bom dia', t) + msg(conv, :bot, 'Oi!', t + 5.minutes) + msg(conv, :customer, 'Qual preço?', t + 25.hours) # dentro da janela 30h + msg(conv, :bot, 'R$ 140', t + 25.hours + 1.minute) + + result = described_class.new(contact: contact).call + expect(result[:interactions_count]).to eq(1) + end + + it 'opens a new interaction when gap > 30h' do + conv1 = open_conversation(created_at: 3.days.ago) + conv2 = open_conversation(created_at: 1.hour.ago) + + t1 = 3.days.ago + msg(conv1, :customer, 'Oi bom dia', t1) + msg(conv1, :bot, 'Oi!', t1 + 5.minutes) + msg(conv1, :customer, 'Reservar hoje', t1 + 10.minutes) + msg(conv1, :bot, 'Ok confirmo', t1 + 15.minutes) + + t2 = 1.hour.ago + msg(conv2, :customer, 'Voltei pra marcar outra', t2) + msg(conv2, :bot, 'Claro, qual data?', t2 + 5.minutes) + msg(conv2, :customer, 'Sexta que vem', t2 + 10.minutes) + msg(conv2, :bot, 'Anotado', t2 + 15.minutes) + + result = described_class.new(contact: contact).call + expect(result[:interactions_count]).to eq(2) + expect(result[:is_recurring]).to be(true) + end + end +end