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

129
docs/REALTIME.md Normal file
View File

@@ -0,0 +1,129 @@
# Monitoramento em tempo real (WebSocket)
Agente.md secao 54-55 (filas/ramais) e 161 (WebSocket multi-tenant). Esta
fase entrega a infraestrutura de push em tempo real — o consumo visual
("Monitoramento → Filas/Ramais", cards coloridos) fica pra fase Frontend.
## Requisito central: tenant-scoped no servidor
Secao 161: "Não transmitir tudo e filtrar somente no browser." O gateway
(`apps/api/src/realtime/realtime.gateway.ts`) só tem **um** ponto de
emissão, `broadcastToTenant()`, e ele sempre usa `server.to(room)` — nunca
`server.emit()` global. Cada socket só entra na room do próprio tenant
(`tenant:<tenantId>`) na conexão, nunca escolhe a room ele mesmo.
## Autenticação na conexão
Socket.io não usa headers HTTP como uma request REST — o client manda o
access token em `socket.handshake.auth.token` (não em `Authorization`).
`RealtimeGateway.handleConnection`:
1. Rejeita (`disconnect(true)`) se não tiver token.
2. Valida o JWT (`verifyAccessToken`, mesmo helper usado no `JwtAuthGuard`).
3. Exige `tenantId` no token (mesmo princípio do `PermissionGuard`: nunca
um tenant vindo do client, só do JWT já emitido por `/auth/select-tenant`).
4. Exige a permission `monitoring.view` (`userHasPermission`, já existia
desde a fase RBAC — só não tinha nenhum consumidor ainda).
5. Só então entra na room do tenant.
Testado: token ausente/inválido → desconectado na hora, nenhum evento
vaza. Token válido → entra na room certa, recebe eventos do próprio tenant.
## Origem dos eventos
Um único canal Redis, `b2bcall:events` (o mesmo que já existia desde a fase
Event Socket) — o `RealtimeRedisBridge` (`apps/api`) assina esse canal com
uma conexão dedicada (pub/sub exige conexão própria, não pode compartilhar
com a usada pra comandos) e reencaminha pra `broadcastToTenant()` quando o
evento já tem `tenantId` resolvido; sem tenant resolvido, o evento é
descartado (nenhuma room saberia receber).
Dois produtores publicam nesse canal:
1. **`b2bcall-fs-events`** (eventos do FreeSWITCH, normalizados — ver
`packages/telephony/src/normalize-event.ts`). A maioria desses eventos
(`callcenter::info`, `sofia::gateway_state`) não carrega
`b2bcall_tenant_id` como channel variable (só existe a partir do
Predictive Engine) — o tenant é resolvido pelo id do agente/fila embutido
no nome FreeSWITCH (`<uuid>@dominio`), com fan-out sobre os tenants
ativos (mesmo padrão de `trunk-status.ts`), cacheado por id em
`tenant-resolve.ts` (agente/fila nunca troca de tenant, cache nunca fica
desatualizado). Isso só passou a funcionar depois da correção do bug de
subscrição de eventos CUSTOM — ver docs/AGENTS.md e docs/EVENT_SOCKET.md.
2. **`apps/api`** (mudanças no nosso próprio `Agent.state`, via
`realtime-publish.helper.ts`, chamado de dentro de
`agents-me.controller.ts` em login/logout/pause/resume). Aqui o
`tenantId` já vem direto do JWT da requisição HTTP — sem fan-out.
## Tipos de evento emitidos
- `AGENT_STATE_CHANGED`: nosso enum próprio (`OFFLINE`/`AVAILABLE`/
`PAUSED`/...), só muda via login/logout/pause/resume.
- `AGENT_STATUS_CHANGED`: `CC-Agent-State` bruto do mod_callcenter
(`Waiting`/`Receiving`/...) — vocabulário diferente do de cima, não dá
pra misturar (mesmo agente pode estar "AVAILABLE" no nosso enum e
"Receiving" no mod_callcenter simultaneamente, description de momentos
diferentes do ciclo de uma chamada).
- `QUEUE_MEMBER_COUNT`: contagem ao vivo de chamadas esperando por fila
(`CC-Count`) — a peça central da secao 54 ("Chamadas esperando").
- `QUEUE_MEMBER_LEFT`: uma chamada saiu da fila, com `cause`/`cancelReason`
e timestamps de entrada/saída — atendida vs. abandonada, base pro cálculo
futuro de Service Level/Abandon Rate.
- `AGENT_OFFERED_CALL` / `AGENT_BRIDGE_FAILED`: uma chamada foi ofertada a
um agente / falhou ao bridgear (ex.: `USER_NOT_REGISTERED`).
- `CALL_CREATED`/`CALL_ANSWERED`/`CALL_ENDED`: já existiam desde a fase
Event Socket, mas só carregam `tenantId` quando o `b2bcall_tenant_id`
channel variable existir (chamadas puramente sintéticas de teste, como as
usadas pra verificar esta fase, não têm — não chegam no WebSocket).
## Verificado ponta a ponta
Client de teste com `socket.io-client`, autenticado com o JWT de um tenant
de teste, na room `tenant:<id>`:
```
login do agente → AGENT_STATE_CHANGED {state: "AVAILABLE"}
pause → AGENT_STATE_CHANGED {state: "PAUSED"}
resume → AGENT_STATE_CHANGED {state: "AVAILABLE"}
logout → AGENT_STATE_CHANGED {state: "OFFLINE"}
originate null/dummy &callcenter(fila@dominio), com agente logado numa fila:
→ QUEUE_MEMBER_COUNT {count: 3}
→ AGENT_STATUS_CHANGED {state: "Receiving"}
→ AGENT_OFFERED_CALL
→ AGENT_BRIDGE_FAILED {hangupCause: "USER_NOT_REGISTERED"} (ramal sem SIP real registrado)
→ QUEUE_MEMBER_LEFT {cause: "Cancel", cancelReason: "TIMEOUT"}
→ QUEUE_MEMBER_COUNT {count: 2}
token ausente/inválido → socket desconectado na hora, nenhum evento recebido.
```
Todos chegaram só na room do tenant certo, com o `tenantId` batendo.
## O que falta
- **Ramais/extensões (secao 55 completa)**: as cores dependem de "ocupado"
(busy — CHANNEL_ANSWER/HANGUP tied a um ramal específico) e "offline"
(registro SIP — `sofia::register`/`unregister`, cujo mecanismo de entrega
agora funciona, mas nunca foi exercitado com um client SIP real nesta
sessão). Além disso, resolver **qual ramal** um evento de canal pertence
exige extrair o número de discagem dos headers (`Caller-Destination-Number`/
`Channel-Name`), não implementado ainda — e o mesmo limite de domínio
compartilhado entre tenants já documentado em `docs/EXTENSIONS.md` afeta
esse mapeamento. Não implementado; fica pra quando houver um client SIP de
verdade pra testar.
- **Persistir `AGENT_STATUS_CHANGED`/`QUEUE_MEMBER_COUNT` em `Agent.state`
ou numa tabela de snapshot de fila**: hoje só passam pelo WebSocket, não
gravam nada — suficiente pro requisito "tempo real", mas sem histórico
consultável fora da tabela `agent_state_events` (que só reflete
login/logout/pause/resume, não os estados derivados de chamada).
- TME/TMA/Service Level/Abandon Rate (secao 54): dependem de CDR
(fase futura, depois do Predictive Engine na ordem do agente.md secao
232) — `QUEUE_MEMBER_LEFT` já traz os dados brutos (`cause`,
`cancelReason`, timestamps) que vão alimentar esse cálculo quando CDR
existir.
- Dashboard Tenant/Platform (secao 162-163) — fase Frontend.
- Reconexão/reconciliação de estado ao reconectar o WebSocket (perder um
evento por queda de rede momentânea não é recuperável hoje — o client
precisa buscar o snapshot atual via REST depois de reconectar; não existe
endpoint de snapshot ainda).