feat(realtime): monitoramento em tempo real via WebSocket multi-tenant

Fecha agente.md secao 54-55 (infraestrutura) e 161 (WebSocket multi-tenant).
Entrega o pipeline de push em tempo real completo — o consumo visual
("Monitoramento -> Filas/Ramais") fica pra fase Frontend.

Requisito central da secao 161 ("nao transmitir tudo e filtrar so no
browser"): RealtimeGateway tem um unico ponto de emissao,
broadcastToTenant(), sempre server.to(`tenant:<id>`), nunca broadcast
global. Cada socket entra na room do proprio tenant no handshake, nunca
escolhe a room.

Autenticacao na conexao (handshake.auth.token, nao Authorization header):
valida o JWT (mesmo verifyAccessToken do JwtAuthGuard), exige tenantId no
token e a permission monitoring.view (ja existia desde RBAC, sem
consumidor ate agora) — mesmo principio de nunca confiar em tenant_id do
client, so do JWT ja emitido por /auth/select-tenant.

Origem dos eventos: canal Redis unico b2bcall:events (o mesmo desde Event
Socket). Dois produtores: b2bcall-fs-events (eventos do FreeSWITCH,
resolvendo tenantId por fan-out quando nao ha channel variable, ver
tenant-resolve.ts) e apps/api (mudancas no nosso Agent.state via
agents-me.controller, tenantId direto do JWT, sem fan-out).

Bug real achado e corrigido ao construir esta fase: nenhum evento CUSTOM do
ESL (sofia::register, sofia::gateway_state, callcenter::info) jamais
chegava em b2bcall-fs-events nesta sessao inteira. Causa: event_json(...)
mandava "CUSTOM" como ultimo token do comando `event json`, sem subclass
depois — mod_event_socket exige os subclasses logo depois do token CUSTOM
no mesmo comando pra serem entregues. Corrigido separando PLAIN_EVENTS
(viram listener .on()) de CUSTOM_SUBCLASSES (so compoem o comando de
assinatura). Resolve as lacunas ja documentadas em docs/TRUNKS.md e
docs/AGENTS.md. De quebra, corrigido um bug de nome de campo
(CC-Agent-Status, que nao existe -> CC-Agent-State) e um segundo bug real
em trunk-sync.ts (rescan nunca descarregava gateway removido -> agora roda
`killgw` antes do rescan).

Novos tipos normalizados a partir de callcenter::info, com nomes de campo
confirmados contra uma fila real: AGENT_OFFERED_CALL, AGENT_BRIDGE_FAILED,
QUEUE_MEMBER_COUNT (chamadas esperando, secao 54), QUEUE_MEMBER_LEFT (com
cause/cancelReason e timestamps — base pra Service Level/Abandon Rate
quando CDR existir).

Verificado ponta a ponta com um client socket.io real: login/pause/resume/
logout emitindo AGENT_STATE_CHANGED; chamada de teste numa fila com agente
logado emitindo QUEUE_MEMBER_COUNT/LEFT, AGENT_OFFERED_CALL,
AGENT_BRIDGE_FAILED, AGENT_STATUS_CHANGED (CC-Agent-State correto); token
ausente/invalido desconectado na hora, sem vazar nenhum evento.

Achado sistemico durante o teste (documentado, nao corrigido nesta fase):
@@unique combinado com soft delete, sem excluir deletedAt, em
Agent/Extension/Trunk/Queue/PauseReason — nao da pra reusar numero/nome/
codigo depois de apagar. Precisa de indice unico parcial em cada um, fora
do escopo desta fase.

typecheck do workspace inteiro limpo. ~144MB de memoria total nos
containers (fs-events 44MB, fs-config 45MB, freeswitch 26MB, postgres
21MB, redis 8MB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X1HxY46WGU4G1zmVDNKcWw
This commit is contained in:
2026-08-28 11:55:52 -03:00
parent b940ce3e63
commit f051fe3162
15 changed files with 661 additions and 12 deletions

View File

@@ -108,6 +108,20 @@ FreeSWITCH (`agent list`).
- Quota de agentes (`max_agents`) — depende de Plans/Entitlements.
- `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