Atualiza TODO.md/SECURITY.md para refletir a regra de nftables real (SIP/ RTP liberados para RFC1918, AMI/ARI seguem bloqueados incondicionalmente) e documenta em TROUBLESHOOTING.md o caso completo: timeout de registro de softphone causado pelo firewall + confusão entre senha de login web e senha SIP do ramal (duas credenciais diferentes).
174 lines
8.2 KiB
Markdown
174 lines
8.2 KiB
Markdown
# B2BCall — Troubleshooting
|
|
|
|
Problemas reais encontrados durante o desenvolvimento deste projeto e como
|
|
foram diagnosticados/resolvidos — mantido como referência para o futuro.
|
|
|
|
## Softphone não registra ("timeout")
|
|
|
|
**Sintoma**: usuário criou um ramal, colocou usuário/senha num softphone
|
|
real e o registro dava timeout (sem resposta nenhuma do servidor).
|
|
|
|
**Causa raiz nº 1 — firewall**: nftables bloqueava incondicionalmente SIP
|
|
(5060/udp) e RTP (10000-20000) vindos da interface LAN (`ens18`) — regra
|
|
criada na Fase 2 como proteção, mas que também bloqueia qualquer uso real,
|
|
já que o OpenSIPS que ficaria na frente (`docs/OPENSIPS.md`) nunca foi
|
|
implantado. Diagnosticado direto pelo contador da regra:
|
|
`nft list table inet b2bcall_fw` mostrando `udp dport 5060 ... drop` com
|
|
contador subindo a cada tentativa. **Fix**: liberar SIP/RTP para origem
|
|
RFC1918 (rede privada), mantendo AMI/ARI sempre bloqueados — ver
|
|
`docs/SECURITY.md`.
|
|
|
|
**Causa raiz nº 2 — depois de liberar o firewall, "Failed to authenticate"**:
|
|
o usuário estava digitando a **senha de login web** (a do `admin@b2bcall.
|
|
local`) no campo de senha do softphone, em vez da **senha SIP** do próprio
|
|
ramal (que é uma credencial completamente diferente, gerada por ramal).
|
|
Confirmado ativando `pjsip set logger on` e vendo o REGISTER chegando e
|
|
falhando na autenticação. **Lição**: deixar claro na interface (ou pelo
|
|
menos na documentação) que são dois sistemas de credenciais distintos —
|
|
fácil de confundir porque os dois são "a senha que o sistema gerou".
|
|
|
|
**Melhoria feita por causa disso**: antes, a única forma de recuperar uma
|
|
senha SIP esquecida era resetá-la (invalidando a anterior). Agora dá para
|
|
ver e editar a senha SIP diretamente no formulário de edição do ramal
|
|
(`GET /extensions/:id/password`, decifra sob demanda) — diferente da
|
|
senha de login (hash argon2id, irreversível por design), a senha SIP é
|
|
cifrada (reversível) porque o próprio Asterisk precisa dela em texto puro
|
|
para autenticar.
|
|
|
|
## "Body cannot be empty when content-type is set to 'application/json'" em quase todo botão de ação
|
|
|
|
**Sintoma**: usuário real testando no navegador (não `curl`) relatou erro
|
|
"body is empty" ao clicar em redefinir senha de um ramal. Investigação
|
|
mostrou que o mesmo bug afetava **todo** botão de ação sem payload:
|
|
disponibilizar agente, pausar/despausar, publicar dialplan, iniciar/
|
|
pausar/parar campanha, etc.
|
|
|
|
**Causa**: o cliente HTTP central do frontend
|
|
(`apps/frontend/src/lib/api-client.ts`) sempre enviava
|
|
`Content-Type: application/json` mesmo em requisições sem corpo. O
|
|
Fastify rejeita isso com 400 antes de a requisição chegar ao controller
|
|
— o `@Body()` do NestJS nem entra em cena, o parser de body do Fastify já
|
|
barra antes.
|
|
|
|
**Por que passou despercebido por tanto tempo**: todo teste de API deste
|
|
projeto, em toda fase anterior, foi feito via `curl` — e `curl -X POST`
|
|
sem `-H 'Content-Type: ...'` e sem `-d` não define esse header. O
|
|
`fetch()` do navegador (usado pelo cliente HTTP real do frontend) sempre
|
|
define o header por padrão. Ou seja: **testar só via curl não teria
|
|
pegado nunca esse bug** — só apareceu quando um usuário de verdade clicou
|
|
em botões reais no navegador. Lição: `curl` prova que o *backend*
|
|
funciona: não prova que o *frontend* consegue falar com ele.
|
|
|
|
**Fix**: só incluir o header `Content-Type` quando `body !== undefined`,
|
|
na única função `request()` usada por `get`/`post`/`patch`/`delete`.
|
|
|
|
**Bug relacionado, achado na mesma revisão**: `DELETE /api/suppression/:id`
|
|
exige `removalReason` no corpo, mas `api.delete()` nem aceitava um
|
|
argumento de corpo — o botão "Desbloquear" da lista de bloqueio sempre
|
|
falhava com 403. Mesma causa raiz (função de ação sem forma de levar
|
|
payload), mesmo tipo de bug que só um clique real revela.
|
|
|
|
## "Argument calledNumber is missing" ao originar chamada
|
|
|
|
**Sintoma**: `PrismaClientValidationError` ao reservar um lead, mesmo a
|
|
query SQL parecendo correta.
|
|
|
|
**Causa**: `$queryRaw` do Prisma **não aplica** o mapeamento camelCase
|
|
automático que `findMany`/`update` aplicam — colunas retornadas por SQL cru
|
|
mantêm o nome exato da coluna (`phone_normalized`, não `phoneNormalized`).
|
|
|
|
**Fix**: sempre usar alias explícito no SQL: `phone_normalized AS
|
|
"phoneNormalized"`. Ver `apps/dialer-worker/src/lead-repository.ts`.
|
|
|
|
## Pacing do discador oscilando/nunca parando de subir mesmo sem atividade
|
|
|
|
**Sintoma**: no cenário de teste da seção 66 (20 agentes/10 CPS/30% taxa de
|
|
atendimento), o `pacingFactor` continuava subindo mesmo em períodos ociosos,
|
|
gerando concorrência muito acima do esperado.
|
|
|
|
**Causas** (três, compostas):
|
|
1. O cálculo de concorrência total não incluía chamadas já conectadas ao
|
|
agente (`agentConnectedCalls`), subestimando quanto já estava "em uso".
|
|
2. O ajuste de pacing subia mesmo sem nenhuma atividade de discagem no
|
|
ciclo (nada "deu errado", então ele interpretava como "pode subir mais").
|
|
3. Uma única iteração fechava 100% da diferença entre o alvo e o atual,
|
|
causando overshoot.
|
|
|
|
**Fix**: `agentConnectedCalls` entra no total de concorrência; pacing nunca
|
|
sobe durante ociosidade (`hasActivity` gate); fechamento de gap limitado a
|
|
50% por ciclo (`RAMP_FRACTION`); e dampening por raiz quadrada do próprio
|
|
`pacingFactor`. Ver `apps/dialer-worker/src/predictive-engine.ts` e
|
|
`docs/PREDICTIVE_DIALER.md`.
|
|
|
|
## AMI: comando `Command` não retorna o output esperado
|
|
|
|
**Sintoma**: parsing manual do protocolo AMI para a action `Command`
|
|
(usada por diagnóstico) não batia com a documentação legada
|
|
(`Response: Follows` / `--END COMMAND--`).
|
|
|
|
**Causa**: o Asterisk 22 usa um formato diferente para `Command`: múltiplos
|
|
headers `Output:` repetidos, um por linha de saída — não o bloco
|
|
`Follows`/`END COMMAND` de versões antigas.
|
|
|
|
**Fix**: reverse-engineering via socket TCP cru (script de debug dedicado)
|
|
para confirmar o formato real antes de implementar o parser em
|
|
`packages/telephony`.
|
|
|
|
## Erro de sintaxe no `schema.prisma`: `@default(1_000_000)`
|
|
|
|
**Sintoma**: `prisma generate` falha com erro de parsing.
|
|
|
|
**Causa**: separador de milhar com underscore (`1_000_000`) não é suportado
|
|
na sintaxe do `.prisma` — é um recurso de JS/TS, não do DSL do Prisma.
|
|
|
|
**Fix**: escrever o número sem separadores: `1000000`.
|
|
|
|
## `Prisma.InputJsonValue` rejeita instância de DTO
|
|
|
|
**Sintoma**: `Index signature for type 'string' is missing` ao passar um
|
|
objeto DTO diretamente para um campo `Json` do Prisma (ex.: `AuditLog.after`).
|
|
|
|
**Causa**: uma instância de classe (mesmo com os campos certos) não
|
|
satisfaz a assinatura de índice que `InputJsonValue` exige — só um objeto
|
|
literal simples satisfaz.
|
|
|
|
**Fix**: espalhar em um objeto literal: `after: { ...dto }` em vez de
|
|
`after: dto`.
|
|
|
|
## Redis: agentes travados em `IN_CALL` para sempre
|
|
|
|
**Sintoma**: depois de uma chamada, o agente nunca voltava a `AVAILABLE`.
|
|
|
|
**Causa**: o "claim" de agente disponível só marcava `IN_CALL` na conexão —
|
|
não havia nenhum mecanismo simétrico de liberação após o fim da chamada.
|
|
|
|
**Fix**: `agent-call-binding.ts#releaseAgentAfterCall`, chamado
|
|
explicitamente quando a chamada termina, com suporte a `WRAP_UP`
|
|
intermediário antes de `AVAILABLE`.
|
|
|
|
## Corrida entre duas campanhas reivindicando o mesmo agente
|
|
|
|
**Sintoma** (encontrado em revisão de código, não em produção):
|
|
implementação inicial de `claimAvailableAgent` usava `findFirst` + update
|
|
separado — janela de corrida entre duas campanhas concorrentes.
|
|
|
|
**Fix**: reescrito com `UPDATE ... WHERE id = (SELECT ... FOR UPDATE SKIP
|
|
LOCKED LIMIT 1) RETURNING ...`, mesmo padrão atômico já usado para reserva
|
|
de leads.
|
|
|
|
## Diagnóstico geral
|
|
|
|
Para qualquer problema não listado acima:
|
|
|
|
1. `bash scripts/healthcheck.sh` — descarta problema de infraestrutura
|
|
básica primeiro.
|
|
2. `docker compose logs -f <serviço>` — logs estruturados JSON, procurar
|
|
por `"level":50` (error) ou `"level":40` (warn).
|
|
3. `docker exec b2bcall-asterisk asterisk -rx "..."` — ver `docs/ASTERISK.md`
|
|
para comandos úteis.
|
|
4. Consultar `AuditLog` (`GET /api/audit`) se o problema envolve uma ação
|
|
específica de um usuário.
|
|
5. Verificar `docker compose ps` — um container "unhealthy" quase sempre
|
|
aponta a causa raiz de problemas em cascata nos serviços que dependem
|
|
dele.
|