Files
B2BCall-dialer/docs/AGENTS.md
Matheus 7b62ad3d82 feat(entitlements,campaigns): plans/quotas + campanhas, leads, lista de bloqueio
Fecha duas fases: Plans/Entitlements (agente.md secao 56-62), que tinha
ficado pra trás desde o inicio, e Campanhas/Leads/Lista de Bloqueio (secao
63-71).

## Plans/Entitlements

A ordem de implementacao da propria especificacao (secao 232) coloca
Plans/Entitlements logo depois de PostgreSQL RLS, bem antes de FreeSWITCH
— mas o build seguiu direto sem essa peca, e toda fase desde entao
documentou "quota depende de Plans/Entitlements" como pendencia
(EXTENSIONS.md, TRUNKS.md, AGENTS.md, QUEUES.md, agora todas atualizadas).
Fechado agora porque Campanhas precisa de max_campaigns e o proximo CPS
Limiter vai precisar de max_cps/max_concurrent_calls.

- plans: catalogo compartilhado entre tenants (sem RLS, nao e' tenant-
  scoped) com todos os campos de entitlement da secao 56. Campo de limite
  null = "sem limite", nunca "sem plano" — tenants.plan_id e' obrigatorio,
  nunca null (secao 56: nao espalhar `if plan == PRO` pelo codigo).
- Migration hand-escrita: cria plans, insere seed "trial", faz backfill de
  plan_id pros tenants ja existentes, so' depois torna NOT NULL (Postgres
  nao deixa NOT NULL sem default em tabela nao-vazia).
- packages/entitlements (pacote novo): assertQuota/assertFeatureEnabled,
  erros mapeados pra 403 no DomainExceptionFilter.
- Retrofit em Extensions/Trunks/Agents/Queues: contam linhas ativas e
  checam quota antes de criar.

## Campanhas, Leads, Lista de Bloqueio

Deliberadamente so' o modelo/CRUD/maquina de estados — o motor que de fato
origina chamadas (PredictiveDialerEngine, secao 72-86: dados em tempo
real, EWMA, CPS distribuido, reserva atomica de lead, lock de campanha,
bgapi originate, controle de abandono, retry) e' um sistema grande o
suficiente pra merecer fase propria (secao 72: "nao e' so' `for lead ->
originate`").

- campaigns/leads/suppression_entries (tenant-scoped, RLS).
- Maquina de estados da campanha (secao 64-66): start/pause/drain/stop com
  tabela de transicoes validas — transicao invalida retorna 400, nunca
  ignora silenciosamente. Apagar bloqueado enquanto RUNNING/DRAINING.
- packages/shared/src/phone.ts (secao 70): normalizacao dedicada,
  preparada pra E.164 completo, so' BR implementado.
- Importacao CSV em batches de 1000 (secao 69): detecta duplicado (dentro
  do CSV + contra leads existentes), checa lista de bloqueio (importa como
  DO_NOT_CALL, nao descarta), retorna {total, valid, invalid, duplicates,
  imported, suppressed}.
- Lista de bloqueio (secao 71): CRUD tenant-scoped.

Verificado ponta a ponta: campanha com queueId/trunkId invalido e
pacingMin > pacingMax rejeitados; CSV de 5 linhas (1 invalida, 1
duplicada, 1 bloqueada) importado corretamente; start->pause->drain->stop
e transicoes invalidas todas corretas; 3a campanha rejeitada por quota
(max_campaigns=2 do plano trial); 6a extensao rejeitada por quota
(max_extensions=5). Suites de teste existentes (tenant-isolation, auth)
atualizadas pro novo Tenant.planId obrigatorio e passando.

typecheck do workspace inteiro limpo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
2026-08-28 12:26:47 -03:00

7.9 KiB

Agentes, Tiers e Pausas

Agente.md secao 45-49 e 52. Completa o Call Center: depois desta fase, um usuário autenticado consegue logar como agente, entrar numa fila real, se pausar e voltar — tudo refletido de verdade no FreeSWITCH.

Modelo

  • agents (tenant-scoped, RLS): liga User (login) → Extension (ramal SIP) → identidade de agente (User/Agent/Extension separados, agente.md secao 45). state espelha o estado real (secao 46), nunca escrito direto pela API — só via login/logout/pause/resume.
  • tiers: Queue↔Agent (secao 52) — level/position espelham 1:1 o mod_callcenter.
  • agent_sessions: uma linha por ciclo login→logout.
  • agent_state_events: histórico de transições de estado (auditoria).
  • pause_reasons/agent_pause_events (secao 48).

Mecanismo: 100% dinâmico, sem arquivo

Confirmado na fase anterior (Queues) via help callcenter_config: agentes e tiers não têm representação em XML — só comandos ESL diretos (agent add/del/set status/set contact, tier add/del). Isso muda o padrão de sincronização: em vez de "regenerar todos os arquivos" (Trunks/Queues), apps/api publica uma mensagem por ação, com payload ({tenantId, agentId, action} em b2bcall:agents:sync; {tenantId, queueId, agentId, level, position, action} em b2bcall:tiers:sync) — b2bcall-fs-config recebe e aplica o comando correspondente.

Achados reais confirmados manualmente antes de codar

Rodei cada comando contra o FreeSWITCH real antes de escrever qualquer lógica de sync (mesmo método usado nas fases anteriores):

  • agent add em cima de um agente que já existe: erro ("Agent already exist!") — não é idempotente. tier add duplicado: mesmo erro ("Tier already exist!"). Ambos capturados e ignorados no sync (.catch(() => undefined)), já que essa condição é esperada em qualquer resync.
  • tier del/agent del em algo que não existe: não dá erro (+OK) — seguro chamar sem checar existência antes.
  • agent set status só aceita 3 valores exatos: Available, On Break, Logged Out — qualquer outro string dá -ERR Invalid Agent Status! (testado deliberadamente). Os estados derivados de chamada do nosso enum (RESERVED/RINGING/IN_CALL/WRAP_UP) não têm status próprio no mod_callcenter — mapeiam pra "Available" (só o state, campo separado, muda sozinho conforme a chamada progride — não escrevemos isso).
  • Achado de corrida real, visto ao testar o fluxo completo: como um agente só passa a existir no FreeSWITCH no login (agent add roda ali, não na criação do registro Agent), atribuir um tier (POST /queues/:id/agents) antes do primeiro login do agente falha silenciosamente do lado do FreeSWITCH (-ERR Agent not found!, logado mas não propagado como erro HTTP). Por isso o login sempre re-sincroniza todos os tiers do agente depois de garantir que ele existe — a auto-correção aconteceu exatamente assim no teste real.

Fluxo de login (agente.md secao 47)

POST /agents/me/login
  → valida usuário (JWT) e ramal (Agent.extensionId precisa existir)
  → cria agent_session
  → agent add (idempotente via catch) + set contact (dial-string do ramal)
  → set status Available
  → re-sincroniza todos os tiers do agente
  → Agent.state = AVAILABLE

POST /agents/me/logout|pause|resume seguem o mesmo padrão — sempre sobre o agente do próprio usuário autenticado (nunca um agentId arbitrário do client, mesmo princípio de nunca confiar em tenant_id/ids sensíveis vindo do frontend sem checar contra o JWT).

Verificado ponta a ponta

POST /extensions {"number":"2000",...}
POST /agents {userId, extensionId, name}
POST /queues {"name":"Suporte"}
POST /queues/:queueId/agents {agentId}          → tier add falha (agente ainda nao existe)
POST /agents/me/login                            → agent add + contact + status Available
                                                  → tier re-sync (dessa vez funciona)

callcenter_config agent list
→ status=Available, contact={ignore_early_media=true}user/2000@b2bcall.local ✓
callcenter_config tier list
→ queue|agent com state=Ready ✓

POST /agents/me/pause {pauseReasonId}  → status=On Break ✓
POST /agents/me/resume                 → status=Available ✓
POST /agents/me/logout                 → status=Logged Out ✓

Todos os 4 estados confirmados batendo entre o banco (Agent.state) e o FreeSWITCH (agent list).

O que falta

  • Estados derivados de chamada (RINGING, IN_CALL, WRAP_UP, RESERVED) — o mecanismo de entrega do callcenter::info foi corrigido e confirmado funcionando (ver seção abaixo), mas gravar esses estados de volta em Agent.state ainda não foi implementado — hoje o campo só muda via login/logout/pause/resume. O estado real do mod_callcenter (CC-Agent-State: Waiting/Receiving/...) já está disponível em tempo real via WebSocket (docs/REALTIME.md); persistir isso em Agent.state fica pra quando o Predictive Engine/CDR precisarem consultar esse histórico.
  • Tela do agente (secao 49) — fase Frontend.
  • Quota de agentes — implementada na fase Plans/Entitlements (ver docs/ENTITLEMENTS.md), assertQuota chamado antes de criar.
  • PauseReason.maxDuration existe no modelo mas não é aplicado automaticamente ainda (ninguém força o fim da pausa ao expirar).
  • Achado ao testar a fase Realtime Monitoring, sistêmico (não é só Agent): @@unique([tenantId, userId]) em Agent não exclui deletedAt — apagar um agente (soft delete) e tentar criar outro pro mesmo usuário no mesmo tenant falha com Unique constraint failed, porque a linha apagada continua ocupando o slot único indefinidamente. Confirmado ao vivo durante o teste desta fase. O mesmo padrão (@@unique combinado com soft delete, sem excluir deletedAt) existe em pelo menos mais 4 models: Extension (tenantId, number), Trunk (tenantId, name), Queue (tenantId, name), PauseReason (tenantId, code) — todos vão ter o mesmo problema (não dá pra reusar um número/nome/código depois de apagar). Precisa de um índice único parcial (WHERE deleted_at IS NULL) em cada um — não corrigido nesta fase (é uma migration própria tocando 5 tabelas, fora do escopo de Realtime Monitoring); fica registrado aqui pra não se perder.

Correção: eventos CUSTOM (callcenter::info) nunca chegavam

Achado ao testar esta fase ponta a ponta com uma chamada real de teste (originate null/dummy &callcenter(fila@dominio)): nenhum evento CUSTOM aparecia em b2bcall-fs-events, apesar do agente ser ofertado a chamada de verdade (agent list mostrava last_offered_call mudando). Causa raiz: event_json(...SUBSCRIBED_EVENTS) mandava "CUSTOM" como último argumento do comando event json, sem nenhum subclass depois — o mod_event_socket do FreeSWITCH exige que os subclasses (callcenter::info, sofia::register, sofia::gateway_state, ...) venham logo depois do token CUSTOM no mesmo comando, senão zero eventos CUSTOM são entregues (de qualquer subclass). Afetava também sofia::gateway_state (ver correção equivalente em docs/TRUNKS.md).

Corrigido separando PLAIN_EVENTS (nomes normais, cada um vira um listener .on()) de CUSTOM_SUBCLASSES (só compõem o comando event json, nunca viram listener — o client ESL sempre emite "CUSTOM" como nome de evento, com o subclass real no header Event-Subclass).

De quebra, corrigido também o nome de campo errado em normalizeCustomEvent: o código lia CC-Agent-Status, que não existe — o campo real é CC-Agent-State. Os CC-Action reais observados no teste (úteis pro monitoramento de filas, agente.md secao 54): agent-offering, agent-state-change, bridge-agent-fail, member-queue-end (com CC-Cause/CC-Cancel-Reason e timestamps de entrada/saída — abandono vs. atendida), members-count (contagem ao vivo de chamadas esperando por fila). Ver packages/telephony/src/normalize-event.ts.