Achado real reportado pelo usuário: "não achei como deixar o agente
online, tem algum perfil ou tipo de usuario especifico?" — não era
permissão nenhuma. POST /agents/me/login|pause|resume|logout sempre
funcionaram sem exigir permissão (só JwtAuthGuard, resolvem "meu
agente" pelo userId do próprio token). O problema: nenhuma tela chamava
esses endpoints — só existia o CRUD admin de Agentes (criar o vínculo
usuário+ramal), nunca um controle pro próprio usuário logado.
Adicionado GET /agents/me (404 = usuário sem Agent neste tenant, não um
erro — é assim que o widget decide se aparece) e GET /agents/me/pause-
reasons (motivos de pausa sem exigir agents.view, que listaria TODOS os
agentes do tenant — permissão que o role "agent" nunca teve e não devia
ganhar só pra isso).
AgentStatusWidget na topbar (visível em qualquer página do app do
tenant) só aparece pra quem tem Agent vinculado: Offline -> Entrar ->
Disponível -> Pausar (escolhe motivo) -> Em pausa -> Retomar/Sair.
Achado real construindo o widget: as Server Actions de login/logout/
pause/resume exportadas como arrow function que só repassavam
argumentos quebravam em runtime ("Server Actions must be async
functions") — o mesmo bug já documentado antes nesta sessão (wizard de
campanhas). Corrigido declarando como async function de verdade.
Testado ponta a ponta com Playwright, logado como um agente de teste
real (usuário novo, role "agent", Agent vinculado a um ramal real): o
fluxo completo (entrar/pausar/retomar/sair) funciona pela tela, sem
nenhuma chamada de API manual.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
9.3 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): ligaUser(login) →Extension(ramal SIP) → identidade de agente (User/Agent/Extensionseparados, agente.md secao 45).stateespelha 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 addem cima de um agente que já existe: erro ("Agent already exist!") — não é idempotente.tier addduplicado: 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 delem algo que não existe: não dá erro (+OK) — seguro chamar sem checar existência antes.agent set statussó 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ó ostate, 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 addroda ali, não na criação do registroAgent), 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::infofoi corrigido e confirmado funcionando (ver seção abaixo), mas gravar esses estados de volta emAgent.stateainda 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 emAgent.statefica 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),assertQuotachamado antes de criar.PauseReason.maxDurationexiste 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])emAgentnão excluideletedAt— apagar um agente (soft delete) e tentar criar outro pro mesmo usuário no mesmo tenant falha comUnique constraint failed, porque a linha apagada continua ocupando o slot único indefinidamente. Confirmado ao vivo durante o teste desta fase. O mesmo padrão (@@uniquecombinado com soft delete, sem excluirdeletedAt) 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.
Widget de status na topbar (PHASE 62)
Achado real reportado pelo usuário: "não achei como deixar o agente
online, tem algum perfil ou tipo de usuário específico?" — não era
permissão nenhuma: POST /agents/me/login|pause|resume|logout
(apps/api/src/agents/agents-me.controller.ts) sempre funcionaram sem
exigir permissão nenhuma (só JwtAuthGuard, resolvem "meu agente" via
userId do próprio token — nunca aceitam um agentId do client). O
problema era que NENHUMA tela chamava esses endpoints — só existia o CRUD
admin de Agentes (criar o vínculo usuário+ramal em Call Center >
Agentes).
Adicionado GET /agents/me (404 = usuário sem Agent neste tenant, não um
erro) e GET /agents/me/pause-reasons (motivos de pausa sem exigir
agents.view, que listaria TODOS os agentes do tenant — permissão que o
role "agent" nunca teve e não devia ganhar só pra isso). AgentStatusWidget
na topbar (visível em qualquer página do app do tenant, apps/app/layout.tsx
busca /agents/me uma vez por request) só aparece pra quem tem Agent
vinculado: Offline → "Entrar" → Disponível → "Pausar" (escolhe motivo) →
Em pausa → "Retomar"/"Sair".
Testado ponta a ponta com Playwright, logado como um agente de teste real (usuário novo, role "agent", Agent vinculado a um ramal real): o fluxo completo (entrar/pausar/retomar/sair) funciona pela tela, sem nenhuma chamada de API manual.