Pular para conteúdo

Sistema de Expiracao de Pesquisas

Status: Implementado em develop e staging (branch feat/survey-expiration + fix/sent-at-on-immediate-send)

Data: 2026-06-02

Versao: 1.0

Contexto

Antes desta implementacao, execucoes (SurveyRun) ficavam eternamente com status pending ou in_progress quando o cliente nao respondia ao convite. Isso bloqueava o reenvio da mesma pesquisa para aquele contato indefinidamente, ja que ha uma regra em backend/surveys/views.py que impede disparo de pesquisa quando ha run ativa para o mesmo (survey, contact).

O sistema agora expira automaticamente runs antigas via tarefa Celery e tambem permite cancelamento manual pelo operador.


Modelo de Dados

Campos relevantes em SurveyRun

Python
# backend/responses/models.py
class SurveyRun(TimeStampedModel):
    STATUS = (
        ('pending', 'Aguardando Inicio'),       # Convite criado/enviado, aguardando SIM
        ('in_progress', 'Em Andamento'),        # Cliente respondeu SIM, fluxo iniciado
        ('completed', 'Finalizada com Sucesso'),
        ('expired', 'Expirada (Tempo Esgotado)'),
        ('opt_out', 'Cancelada pelo Usuario'),  # Cliente respondeu NAO
    )

    created_at        # Quando a run foi criada no banco
    sent_at           # Quando o template foi enviado via Meta API
    started_at        # Quando o cliente respondeu SIM e iniciou as perguntas
    last_activity_at  # NOVO: ultima interacao do usuario (qualquer resposta)
    completed_at      # Quando entrou em status final

Indices criados

Python
1
2
3
4
indexes = [
    ...
    models.Index(fields=['status', 'last_activity_at'], name='run_status_activity'),
]

Permite que a task de expiracao filtre eficientemente runs antigas.


Regras de Expiracao

Tres regras independentes cobrem cenarios distintos:

Regra A: pending_no_send

Quando: status='pending' AND sent_at IS NULL

Janela: SURVEY_EXPIRY_PENDING_NO_SEND_HOURS (default 24h)

Referencia: created_at

Significado: Run foi criada mas o convite nunca chegou a ser enviado via WhatsApp (falha tecnica de envio). Cliente final nunca viu nenhuma mensagem.

Excecao: se is_scheduled=True e scheduled_at no futuro, a regra NAO se aplica (a run esta aguardando agendamento legitimo).

Regra B: pending_no_reply

Quando: status='pending' AND sent_at IS NOT NULL

Janela: SURVEY_EXPIRY_PENDING_NO_REPLY_HOURS (default 72h)

Referencia: sent_at

Significado: Template foi enviado para o cliente, mas ele nao respondeu SIM nem NAO. Apos 3 dias considera-se que ele optou por nao participar.

Regra C: in_progress_abandoned

Quando: status='in_progress'

Janela: SURVEY_EXPIRY_IN_PROGRESS_HOURS (default 24h)

Referencia: last_activity_at (ou started_at ou created_at como fallback)

Significado: Cliente comecou a responder mas parou no meio. Apos 24h sem nova resposta, considera-se abandono. Esta janela esta alinhada com a janela de 24h do WhatsApp Business API -- alem dela, o bot nao poderia mais enviar mensagens livres.


Configuracao

As janelas sao configuraveis via variaveis de ambiente:

Bash
1
2
3
4
# backend/.env
SURVEY_EXPIRY_PENDING_NO_SEND_HOURS=24
SURVEY_EXPIRY_PENDING_NO_REPLY_HOURS=72
SURVEY_EXPIRY_IN_PROGRESS_HOURS=24

Lidas em backend/app/settings.py:

Python
1
2
3
4
5
6
7
8
9
SURVEY_EXPIRY_PENDING_NO_SEND_HOURS = config(
    'SURVEY_EXPIRY_PENDING_NO_SEND_HOURS', default=24, cast=int
)
SURVEY_EXPIRY_PENDING_NO_REPLY_HOURS = config(
    'SURVEY_EXPIRY_PENDING_NO_REPLY_HOURS', default=72, cast=int
)
SURVEY_EXPIRY_IN_PROGRESS_HOURS = config(
    'SURVEY_EXPIRY_IN_PROGRESS_HOURS', default=24, cast=int
)

Implementacao

1. Metodos no Model (SurveyRun)

Python
# backend/responses/models.py

def get_expiry_info(self) -> dict:
    """
    Retorna info de expiracao para esta run.

    Returns:
        {
            'is_expired': bool,
            'will_expire_at': datetime | None,
            'hours_remaining': float | None,
            'reason': 'pending_no_send' | 'pending_no_reply' |
                      'in_progress_abandoned' | 'not_applicable',
            'reason_display': str (descricao amigavel)
        }
    """

def is_expired(self) -> bool
def mark_as_expired(self, save=True) -> None
def touch_activity(self, save=True) -> None  # Atualiza last_activity_at

2. Task Celery (expire_stale_runs)

Python
1
2
3
4
5
6
7
8
# backend/responses/tasks.py

@shared_task(bind=True, max_retries=3)
def expire_stale_runs(self):
    """
    Aplica as 3 regras de expiracao em massa.
    Executada a cada 1 hora pelo Celery Beat.
    """

A task usa QuerySet.update() (bulk) ao inves de iterar com .save() para performance. Filtra usando os indices criados.

Retorna:

Python
1
2
3
4
5
6
7
{
    'status': 'success',
    'expired_total': N,
    'rule_a': N1,  # pending_no_send
    'rule_b': N2,  # pending_no_reply
    'rule_c': N3,  # in_progress_abandoned
}

3. Registro de atividade do usuario

Em backend/whatsapp/services/survey_service.py:

Python
1
2
3
4
5
6
7
8
9
if active_run:
    if message_log:
        message_log.run = active_run
        message_log.save(update_fields=['run'])

    # Registra a interacao do usuario
    active_run.touch_activity()

    return self._handle_survey_response(active_run, message_text, button_id)

Toda resposta do cliente (qualquer tipo: texto, botao, lista, midia) atualiza last_activity_at. Isso evita marcar como abandonada conversas em andamento legitimas.

4. Auto-heal no envio

Em backend/surveys/views.py:

Python
if existing_run:
    expiry_info = existing_run.get_expiry_info()
    if expiry_info.get('is_expired'):
        # Auto-heal: expira na hora se a task ainda nao rodou
        existing_run.mark_as_expired()
        # Continua o fluxo abaixo para criar uma nova run
    else:
        # Bloqueia com payload estruturado
        failed_results.append({...})
        continue

Garante que mesmo se o operador disparar segundos antes da task rodar, o sistema reconhece runs ja expiradas.

5. Endpoint manual force-expire

HTTP
POST /api/v1/survey-runs/{id}/force-expire/
Authorization: Bearer <jwt>

Marca a run como expired imediatamente. Disponivel no botao "Cancelar Execucao" na tela de detalhes da run, visivel apenas quando status in ('pending', 'in_progress').


Fluxo Completo

Text Only
[Operador cria pesquisa]
       |
       v
[POST /api/v1/surveys/{id}/start_survey/]
       |
       +--> Para cada contato:
       |    |
       |    +-> Ha run ativa para este (survey, contact)?
       |    |   |
       |    |   +-> SIM, ja expirada => Auto-heal (mark_as_expired) e continua
       |    |   +-> SIM, ainda valida => Bloqueia com payload estruturado
       |    |   +-> NAO => Cria nova run
       |    |
       |    +-> meta_service.send_initial_survey_message() (template)
       |    |
       |    +-> Sucesso? => survey_run.sent_at = now()
       |
       v
[Tempo passa, cliente responde "SIM"]
       |
       v
[meta_webhook recebe mensagem]
       |
       +--> find_contact_by_phone() (lida com problema do "9" do BR)
       +--> Identifica active_run
       +--> active_run.touch_activity()  <-- registra atividade
       +--> survey_service processa SIM, status='in_progress'
       |
       v
[Cliente segue respondendo perguntas]
       |
       +--> Cada resposta: touch_activity()
       |
       v
[Cliente termina ou para de responder]
       |
       v
[Celery Beat dispara expire_stale_runs a cada 1h]
       |
       +-> Regra A: pending sem sent_at >24h => expired
       +-> Regra B: pending com sent_at >72h => expired
       +-> Regra C: in_progress sem activity >24h => expired
       |
       v
[Operador pode disparar nova pesquisa para o mesmo contato]

Testes Realizados

Teste Resultado
Task expire_stale_runs executada manualmente 15 runs antigas expiradas (Regra A)
Endpoint force-expire em Run pending HTTP 200, status -> expired, completed_at preenchido
Bloqueio com payload estruturado Retorna error_code, hours_remaining, reason_display
Backfill de sent_at retroativo 17 runs migraram de Regra A (24h) para Regra B (72h)
touch_activity() em resposta de cliente last_activity_at atualizado corretamente

Operacao

Verificar estado das runs

Bash
1
2
3
4
5
6
docker compose exec backend python manage.py shell -c "
from responses.models import SurveyRun
for r in SurveyRun.objects.filter(status='pending'):
    info = r.get_expiry_info()
    print(f'#{r.id} {r.contact.name} | {info[\"hours_remaining\"]:.1f}h | {info[\"reason\"]}')
"

Executar expiracao manualmente

Bash
1
2
3
4
5
docker compose exec backend python manage.py shell -c "
from responses.tasks import expire_stale_runs
result = expire_stale_runs.apply().get()
print(result)
"

Listar tarefas periodicas

Bash
1
2
3
4
5
docker compose exec backend python manage.py shell -c "
from django_celery_beat.models import PeriodicTask
for t in PeriodicTask.objects.filter(enabled=True):
    print(f'  - {t.name} ({t.task}) every {t.interval}')
"

Migrations Aplicadas

  • 0006_add_last_activity_at: adiciona campo last_activity_at + index
  • 0007_create_expire_runs_task: registra PeriodicTask "Expirar pesquisas antigas" (1h)

Endpoints Modificados/Criados

Metodo URL Acao
POST /api/v1/surveys/{id}/start_survey/ Modificado: payload estruturado quando bloqueia + auto-heal
POST /api/v1/survey-runs/{id}/force-expire/ Novo: cancela manualmente uma execucao

Payload de bloqueio estruturado

JSON
{
  "success": [],
  "failed": [
    {
      "phone": "5511999999999",
      "error_code": "survey_already_active",
      "error": "Ja existe uma pesquisa ativa para este contato.",
      "contact_name": "Joao Silva",
      "current_run_id": 142,
      "current_status": "pending",
      "current_status_display": "Aguardando Inicio",
      "sent_at": "2026-05-30T14:30:00Z",
      "will_expire_at": "2026-06-02T14:30:00Z",
      "hours_remaining": 47.5,
      "reason": "pending_no_reply",
      "reason_display": "Convite enviado, aguardando o cliente responder SIM"
    }
  ],
  "summary": {"total": 1, "success_count": 0, "failed_count": 1}
}

O frontend trata esse payload diferenciado em frontend/src/components/runs/RunNewClient.tsx mostrando uma mensagem agrupada com tempo restante por contato.


Bug Relacionado Corrigido

sent_at nao era preenchido no envio imediato: O endpoint start_survey enviava o template mas nao atualizava sent_at na SurveyRun. Como consequencia, todas as runs caiam na Regra A (24h) em vez da Regra B (72h), reduzindo o prazo do cliente em 48h.

Correcao em backend/surveys/views.py: apos send_initial_survey_message() retornar True, faz survey_run.refresh_from_db() + seta sent_at = timezone.now().

Commits: 47b6a95 (merge em develop).

Backfill aplicado em producao para as 17 runs ja afetadas, copiando o timestamp do template_invitation mais antigo.