feat: Fase 9/10 — métricas, scripts operacionais, callback/wrap-up e aceite final

Fase 9 (segurança e produção):
- GET /api/metrics: endpoint Prometheus com métricas reais (chamadas,
  agentes, filas, CPS por campanha), protegido por permissão
- scripts/backup.sh, restore.sh, healthcheck.sh, install.sh, update.sh —
  testados contra o ambiente real (backup.sh e healthcheck.sh rodados de
  verdade; install.sh/update.sh validados por inspeção, ambiente atual já
  provisionado)
- POST /api/agent-console/dispose: aplica disposição de chamada de verdade
  (lacuna deixada aberta desde a Fase 6), com ações CALLBACK (agenda
  retorno) e DO_NOT_CALL (suprime automaticamente)
- apps/dialer-worker/src/callback-sweep.ts: reativa leads com callback
  vencido; GET /api/callbacks para consulta
- apps/dialer-worker/src/wrap-up-sweep.ts: transição automática
  WRAP_UP -> AVAILABLE + despausa real na fila do Asterisk. Exigiu corrigir
  main.ts para conectar ao AMI mesmo em DIALER_SIMULATION=true
  (DIALER_SIMULATION deve impedir só originação de chamada, não ações
  administrativas de fila)
- nftables revisado (sem alterações necessárias)
- Documentação completa: INSTALL, OPERATIONS, BACKUP_RESTORE, SECURITY,
  DATABASE, API, ASTERISK, OPENSIPS (não implementado, motivo
  documentado), TROUBLESHOOTING
- README.md e CHANGELOG.md reescritos

Fase 10 (testes e aceite):
- Quality gate completo executado: build/typecheck (7 workspaces), lint,
  46 testes unitários, docker compose config/ps, healthcheck — tudo verde
- Aceite de segurança (seção 92): 13 itens verificados ao vivo contra o
  sistema real, não só por inspeção de código
- Aceite Asterisk (seção 93): os 5 comandos executados e documentados,
  comunicação API->AMI->Asterisk validada
- docs/RELATORIO_FINAL.md: relatório final no formato da seção 96

Todos os fixtures de teste desta fase foram removidos/desativados ao
final. Credenciais de acesso entregues separadamente em CREDENCIAIS.txt
(fora do git, nunca versionado).
This commit is contained in:
2026-08-27 19:16:14 -03:00
parent 6273b32214
commit 80e72881b2
33 changed files with 2024 additions and 39 deletions

72
docs/API.md Normal file
View File

@@ -0,0 +1,72 @@
# B2BCall — Referência da API
Base URL (via Nginx): `http://<host>/api`
Documentação interativa (Swagger, quando `SWAGGER_ENABLED=true`): `http://<host>/api/docs`
## Autenticação
Todas as rotas exigem um `access_token` válido (cookie HttpOnly, definido pelo
login) **exceto** as marcadas `@Public()`:
`/api/auth/login`, `/api/auth/refresh`, `/api/auth/forgot-password`,
`/api/auth/reset-password`, `/api/health`, `/api/health/live`,
`/api/health/ready`, `GET /api`.
Cada rota protegida também é validada contra o RBAC do usuário
(`@RequirePermissions(...)`) — ver `packages/shared/src/permissions.ts` para
o catálogo completo de chaves de permissão.
## Mapa de rotas
| Módulo | Rotas |
|---|---|
| `auth` | `POST /auth/login`, `/refresh`, `/logout`, `/change-password`, `/forgot-password`, `/reset-password`, `GET /auth/me` |
| `users` | `GET /users`, `GET /users/:id`, `POST /users`, `PATCH /users/:id` |
| `roles` | `GET /roles/permissions` (catálogo), `GET/POST/PATCH/DELETE /roles` |
| `audit` | `GET /audit` (filtros: userId, action, entityType, from, to, paginação) |
| `trunks` | `GET/POST/PATCH/DELETE /trunks`, `GET /trunks/:id/status` |
| `extensions` | `GET/POST/PATCH/DELETE /extensions`, `POST /extensions/:id/reset-password` |
| `dialplans` | `GET/POST/PATCH/DELETE /dialplans`, `GET /dialplans/versions`, `POST /dialplans/publish`, `POST /dialplans/versions/:id/rollback` |
| `asterisk-admin` | `GET /asterisk/status`, `GET /asterisk/modules`, `GET /asterisk/diagnostic/allowed-commands`, `POST /asterisk/diagnostic`, `POST /asterisk/reload` |
| `queues` | `GET/POST/PATCH/DELETE /queues`, `POST /queues/:id/members`, `DELETE /queues/:id/members/:agentId` |
| `agents` | `GET/POST/PATCH/DELETE /agents` |
| `agent-console` | `GET /agent-console/me`, `POST /login`, `/available`, `/pause`, `/unpause`, `/logout`, `/dispose` |
| `pause-reasons` | `GET/POST/PATCH/DELETE /pause-reasons` |
| `dispositions` | `GET/POST/PATCH/DELETE /dispositions` |
| `callbacks` | `GET /callbacks?campaignId=` (somente leitura — agendamento via `agent-console/dispose`) |
| `campaigns` | `GET/POST/PATCH/DELETE /campaigns`, `POST /:id/start`, `/pause`, `/stop`, `/drain` |
| leads (sob campanhas) | `GET /campaigns/:campaignId/leads`, `GET .../imports`, `GET .../imports/:importId/rejected.csv`, `POST .../import` |
| `suppression` | `GET/POST /suppression`, `POST /suppression/import`, `DELETE /suppression/:id` |
| `reports` | `GET /reports/calls`, `/calls/export` (CSV), `/metrics`, `/agents/:agentId` |
| `dashboard` | `GET /dashboard`, `/calls-by-hour`, `/campaigns/:id` |
| `compliance` | `GET/PATCH /compliance/settings`, `GET /compliance/indicators` |
| `monitoring` | `GET /monitoring/extensions`, `/queues`, `/agents` |
| `metrics` | `GET /metrics` (Prometheus, texto plano) |
| `health` | `GET /health`, `/health/live`, `/health/ready` |
## Convenções
- Paginação server-side em endpoints que retornam listas potencialmente
grandes (`audit`, `reports/calls`, `suppression`): `page`, `pageSize`,
resposta com `{ items, total, page, pageSize }`.
- Erros nunca vazam detalhes internos: toda resposta de erro inclui
`requestId` para correlação com o log estruturado do servidor.
- Datas em ISO 8601 UTC; conversão de timezone de campanha
(`America/Sao_Paulo` por padrão) acontece no backend, nunca no cliente.
- Segredos (senha de ramal, senha de tronco) nunca retornam em `GET`/`PATCH`
— só no `POST` de criação (senha de ramal) ou nunca em texto puro (senha
de tronco, sempre `secretEncrypted` omitido da resposta).
## Exemplos rápidos
```bash
# Login
curl -c cookies.txt -X POST http://<host>/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@b2bcall.local","password":"..."}'
# Listar troncos (autenticado)
curl -b cookies.txt http://<host>/api/trunks
# Métricas Prometheus
curl -b cookies.txt http://<host>/api/metrics
```

94
docs/ASTERISK.md Normal file
View File

@@ -0,0 +1,94 @@
# B2BCall — Asterisk
## Versão e módulos
Asterisk **22.10.1**, compilado do fonte (Debian trixie não empacota
`asterisk`) — `infrastructure/docker/asterisk.Dockerfile`. Módulos
habilitados: `chan_pjsip` (nunca `chan_sip`, removido/depreciado),
`res_odbc`/`res_config_odbc` (Realtime), `app_queue`, `cdr_adaptive_odbc`,
`cel_odbc`, AMI, ARI.
## Realtime (PJSIP via Postgres)
Objetos PJSIP (`ps_endpoints`, `ps_auths`, `ps_aors`, `ps_contacts`,
`ps_endpoint_id_ips`, `ps_registrations`) vivem no schema `asterisk` do
mesmo Postgres da aplicação — nunca em arquivo estático. Escritos
exclusivamente por `apps/api/src/telephony/pjsip-realtime.service.ts`
quando um Tronco/Ramal é criado/editado pela API.
- DSN ODBC: `infrastructure/asterisk/config/odbc.ini.tpl` → conexão nomeada
`asterisk`, `ConnSettings = SET search_path TO asterisk, public;`.
- `sorcery.conf`/`extconfig.conf` apontam os tipos PJSIP para essa conexão.
- Depois de criar/editar um objeto, a API dispara reload seletivo via AMI
(`pjsip reload`), nunca `core restart`.
## Dialplan e filas (gerados, versionados)
- `infrastructure/asterisk/config/extensions.conf` inclui
`/etc/asterisk-generated/b2bcall-dialplan.conf` — gerado por
`DialplanService` a partir de `DialplanEntry`/`DialplanVersion`
(Postgres). Toda alteração é uma nova `DialplanVersion` com validação
(`dialplan reload` + checagem de erro) e rollback automático se falhar.
- `infrastructure/asterisk/config/queues.conf` inclui
`/etc/asterisk-generated/b2bcall-queues.conf` — gerado a partir de
`Queue` (Postgres). **Membros nunca são estáticos** — adicionados/
removidos via AMI `QueueAdd`/`QueueRemove` no login/logout do agente
(`AgentConsoleService`), para nunca ter duas fontes de verdade
divergentes sobre quem está em qual fila.
- Ambos os arquivos gerados vivem no volume Docker `dialplan-generated`,
compartilhado entre `api` (escreve) e `asterisk` (lê + recarrega).
- **Nunca editar esses dois arquivos gerados manualmente** — qualquer
edição é sobrescrita na próxima publicação pela aplicação.
## AMI / ARI
- AMI: client próprio em `packages/telephony` (TCP raw, sem biblioteca de
terceiros — o protocolo de wire do Asterisk 22 para a action `Command`
usa headers `Output:` repetidos, não o formato legado
`Response: Follows`/`--END COMMAND--` documentado em versões antigas).
- Usado para: Originate, Hangup, QueueAdd/Remove, QueuePause,
QueueStatus, ExtensionState/DeviceState, PJSIP show endpoints/contacts,
reloads seletivos.
- ARI: reservado para controle fino de canais/bridges quando necessário —
não usado extensivamente nesta fase (a maior parte das operações
administrativas é feita via AMI).
- **Nunca expostos além de `127.0.0.1`/rede interna** — bloqueados da LAN
por nftables mesmo estando em `network_mode: host` (ver
`docs/ARCHITECTURE.md` §3.1 e `infrastructure/nftables/`).
## Comandos úteis de diagnóstico
```bash
docker exec b2bcall-asterisk asterisk -rx "core show uptime"
docker exec b2bcall-asterisk asterisk -rx "pjsip show endpoints"
docker exec b2bcall-asterisk asterisk -rx "pjsip show registrations"
docker exec b2bcall-asterisk asterisk -rx "queue show"
docker exec b2bcall-asterisk asterisk -rx "dialplan show b2bcall-healthcheck"
docker exec b2bcall-asterisk asterisk -rx "module show like odbc"
docker exec b2bcall-asterisk asterisk -rx "odbc show all"
```
Um subconjunto seguro e auditado desses comandos também é exposto via
`GET /api/asterisk/diagnostic/allowed-commands` +
`POST /api/asterisk/diagnostic` (allowlist explícita — nunca shell livre,
seção 18) para uso pela interface web sem precisar SSH no servidor.
## Rede
`network_mode: host` (necessário para a faixa de portas RTP — mapear porta
a porta em bridge Docker é inviável). Implicações e mitigação em
`docs/ARCHITECTURE.md` §3.1.
## Limitações conhecidas nesta fase
- `res_pjsip_outbound_registration` pode logar erro no boot se nenhum
registration estiver configurado ainda (nenhum tronco cadastrado) — não é
um erro real, some ao cadastrar o primeiro tronco com `registration`.
- `res_config_ldap` loga ERROR no boot (LDAP não configurado/não usado
neste projeto) — módulo carregado por padrão pelo build, inofensivo.
- `cdr_pgsql`/`cel_pgsql` (variante não-ODBC) não compilam nesta imagem por
falta de `libpq-dev` — não é um problema porque `cdr_adaptive_odbc`/
`cel_odbc` (a variante realmente usada) funcionam normalmente.
- Nenhuma correlação automática CDR↔`DialAttempt` para chamadas **reais**
(não simuladas) além do timeout de segurança da reconciliação — ver
`docs/PREDICTIVE_DIALER.md`.

79
docs/BACKUP_RESTORE.md Normal file
View File

@@ -0,0 +1,79 @@
# B2BCall — Backup e Restore
## O que é backupado
Um único `pg_dump -Fc` do banco `b2bcall` cobre **tudo que precisa
persistir**:
- Schema `public` — usuários, campanhas, leads, chamadas, configurações.
- Schema `asterisk` — troncos/ramais provisionados (ps_endpoints, ps_auths,
ps_aors, ...), CDR, CEL.
Os arquivos gerados em `Telefonia → Dialplan` e `Call Center → Filas`
(`/etc/asterisk-generated/*.conf` dentro do container do Asterisk) **não**
precisam de backup separado: são derivados de `DialplanVersion`/`Queue`
(Postgres) e recriados automaticamente na próxima publicação, caso o volume
Docker seja perdido (agente.md seção 97: "Asterisk não é banco de negócio").
O `.env` é backupado à parte (contém segredos que não estão no banco:
`JWT_*_SECRET`, `SECRETS_MASTER_KEY`, credenciais AMI/ARI/SMTP).
## Backup
```bash
sudo ./scripts/backup.sh
```
Gera em `backups/` (fora do git, `chmod 600`):
- `postgres-<timestamp>.dump` — dump `pg_dump -Fc` (formato custom, comprime
e permite restore seletivo).
- `env-<timestamp>.bak` — cópia do `.env`.
- `FIRST_LOGIN-<timestamp>.txt` — se o arquivo ainda existir no servidor.
Retenção: remove automaticamente backups com mais de 30 dias
(`BACKUP_RETENTION_DAYS` no ambiente para ajustar).
**Agendamento recomendado** (cron, fora do escopo deste repositório):
```cron
0 3 * * * cd /opt/b2bcall && ./scripts/backup.sh >> /var/log/b2bcall-backup.log 2>&1
```
Copie os backups para fora do servidor (outro host, storage externo) — um
backup que só existe no mesmo disco do banco não protege contra falha de
disco.
## Restore
```bash
sudo ./scripts/restore.sh backups/postgres-20260101-030000.dump
```
**Isso é destrutivo**: substitui completamente o conteúdo atual do banco
(`pg_restore --clean --if-exists`). O script pede confirmação explícita
(digitar "restaurar") antes de agir, para acidentes de digitação.
O que o script faz:
1. Para os serviços de aplicação (`api`, `asterisk-events`, `dialer-worker`,
`frontend`, `nginx`) — mantém `postgres`/`redis`/`asterisk` no ar.
2. Executa `pg_restore` contra o Postgres em funcionamento.
3. Reinicia os serviços de aplicação.
Após restaurar, rode `scripts/healthcheck.sh` e confira:
- `pjsip show endpoints` no Asterisk reflete os troncos/ramais do backup.
- Login funciona com um usuário que existia no momento do backup.
## Restaurando o `.env`
Se o `.env` também precisar ser restaurado (ex.: perda total do servidor):
```bash
cp backups/env-<timestamp>.bak .env
chmod 600 .env
```
Isso restaura os segredos (JWT, master key de criptografia) — sem eles, as
senhas de tronco/ramal cifradas no banco restaurado **não podem ser
decifradas**. Por isso o `.env` deve ser guardado com a mesma prioridade
que o dump do banco, não apenas como acessório.

69
docs/DATABASE.md Normal file
View File

@@ -0,0 +1,69 @@
# B2BCall — Banco de Dados
PostgreSQL 17, um único cluster/instância com **dois schemas** que nunca se
misturam (ver `docs/ARCHITECTURE.md` §3.3):
- **`public`** — domínio da aplicação (este documento). Gerenciado 100% por
Prisma (`packages/database/prisma/schema.prisma` + `prisma/migrations/`).
- **`asterisk`** — objetos de Realtime do Asterisk (`ps_endpoints`,
`ps_auths`, `ps_aors`, `ps_contacts`, `ps_endpoint_id_ips`,
`ps_registrations`, `cdr`, `cel`). Criado por
`infrastructure/postgres/init/002-asterisk-realtime.sql` e escrito
exclusivamente por `apps/api/src/telephony/pjsip-realtime.service.ts`
(nunca por SQL solto em outro lugar da aplicação).
## Modelos (schema `public`)
| Modelo | Propósito |
|---|---|
| `User`, `Session`, `PasswordResetToken` | Autenticação (Fase 3) |
| `Role`, `Permission`, `UserRole`, `RolePermission` | RBAC |
| `AuditLog` | Auditoria de todas as ações sensíveis |
| `Trunk`, `Extension`, `ExtensionState` | Telefonia (Fase 4) — `Trunk`/`Extension` são o CRUD da aplicação; os objetos PJSIP reais ficam no schema `asterisk` |
| `DialplanEntry`, `DialplanVersion` | Dialplan estruturado versionado |
| `Queue`, `QueueMember`, `PauseReason` | Call Center (Fase 5) |
| `Agent`, `AgentSession`, `AgentStateEvent`, `AgentPauseEvent` | Máquina de estados do agente — sempre exatamente um `AgentStateEvent` aberto (`ended_at IS NULL`) por agente |
| `Campaign`, `LeadImport`, `Lead`, `DialAttempt` | Discador preditivo (Fase 6). `DialAttempt` é a "chamada" como state machine (seção 36) — `id` próprio, nunca o `UNIQUEID` do Asterisk como PK de negócio |
| `CallDisposition`, `Callback` | Disposição de chamada e agendamento de retorno |
| `SuppressionEntry` | Lista de bloqueio (DNC) |
| `ComplianceSettings` | Parâmetros de compliance (singleton) |
## Decisões importantes
- **Prisma como ORM** (não Drizzle/TypeORM) — ver justificativa em
`docs/ARCHITECTURE.md` §3.6.1.
- **Índices de alto volume**: `Lead(campaignId, status, nextAttemptAt)`,
`DialAttempt` por `campaignId`/`state`/`asteriskUniqueId`,
`AuditLog(createdAt)`, `AuditLog(entityType, entityId)`. Consultas de
relatório usam paginação server-side sempre (nunca `SELECT *` sem filtro
— seção 54).
- **Reserva concorrente de leads**: `SELECT ... FOR UPDATE SKIP LOCKED` via
`$queryRaw` em `apps/dialer-worker/src/lead-repository.ts` — não modelado
via Prisma de alto nível porque a transição atômica READY→RESERVED
precisa de controle fino de lock que o ORM não expõe com segurança
suficiente sob concorrência real.
- **Particionamento**: avaliado, não implementado (volume atual não
justifica). `DialAttempt`/`AuditLog` são os candidatos naturais quando o
volume crescer — desenhados para permitir particionamento por
`started_at`/`created_at` sem migração destrutiva.
- **Segredos**: `Trunk.secretEncrypted` e `Extension.sipPasswordEncrypted`
são cifrados com AES-256-GCM (`packages/shared/src/secret-crypto.ts`),
chave mestra em `SECRETS_MASTER_KEY` (.env, nunca no banco).
## Migrations
```bash
cd packages/database
pnpm exec prisma migrate dev --name <descrição> # desenvolvimento
pnpm exec prisma migrate deploy # produção (usado por scripts/update.sh)
pnpm run seed # permissões, perfis, bootstrap super_admin
```
Nenhuma alteração de schema é feita manualmente em produção — sempre via
migration versionada e commitada (seção 85).
## Backup / Restore
Ver `docs/BACKUP_RESTORE.md`. Resumo: `pg_dump -Fc` do banco inteiro cobre
`public` **e** `asterisk` num único arquivo, já que ambos vivem na mesma
instância Postgres.

92
docs/INSTALL.md Normal file
View File

@@ -0,0 +1,92 @@
# B2BCall — Instalação
## Requisitos
- Debian 13 (trixie) limpo, acesso root.
- **Mínimo para dev/testes/simulação**: 2 vCPU, 2 GiB RAM, 20 GiB disco.
- **Recomendado para produção real com volume de discagem**: 4 vCPU, 8 GiB
RAM (ver `docs/ARCHITECTURE.md` §1 — o ambiente original de
desenvolvimento deste projeto tinha só 1.9 GiB e isso limitou decisões de
arquitetura, como resource limits conservadores por container).
- Rede: um IP privado para o servidor; troncos SIP/OpenSIPS acessíveis por
IP privado (o Asterisk nunca tem IP público — seção 5).
## Instalação automatizada
```bash
git clone <url-do-repositorio> /opt/b2bcall
cd /opt/b2bcall
sudo ./scripts/install.sh
```
O script (`scripts/install.sh`) faz, em ordem:
1. Verifica que está rodando como root.
2. Instala dependências de sistema (git, curl, openssl, Node.js 24 LTS via
NodeSource, pnpm via corepack).
3. Instala Docker Engine + Compose plugin (repositório oficial Docker,
detecta o codename da distro automaticamente).
4. Gera `.env` a partir de `.env.example` com segredos aleatórios fortes
(`scripts/generate-secrets.sh`) — só na primeira instalação, nunca
sobrescreve um `.env` existente.
5. Instala as regras de firewall (`infrastructure/nftables/`) — protege
AMI/ARI/SIP contra a LAN, nunca bloqueia SSH.
6. Aplica `vm.overcommit_memory=1` (recomendado pelo Redis).
7. `pnpm install` no monorepo.
8. `docker compose build` (compila Asterisk do fonte — a etapa mais
demorada, ~5-10 minutos dependendo do hardware).
9. Sobe Postgres e Redis, aguarda ficarem saudáveis.
10. Aplica migrations do Prisma e roda o seed (permissões, perfis,
bootstrap do `super_admin`).
11. Sobe Asterisk e os demais serviços (`docker compose up -d`).
12. Roda `scripts/healthcheck.sh`.
13. Imprime a URL de acesso e o caminho do `FIRST_LOGIN.txt`.
## Primeiro acesso
```bash
cat FIRST_LOGIN.txt
```
Contém e-mail e senha do `super_admin`, gerados aleatoriamente
(agente.md seção 70) — nunca uma senha padrão. O primeiro login **exige**
troca de senha (`mustChangePassword: true`).
**Remova o arquivo do servidor depois do primeiro acesso**:
```bash
shred -u FIRST_LOGIN.txt # ou: rm -f FIRST_LOGIN.txt
```
## Acesso
- Interface web: `http://<ip-do-servidor>/`
- API: `http://<ip-do-servidor>/api` (Swagger em `/api/docs` se
`SWAGGER_ENABLED=true`)
Nenhuma outra porta deve estar acessível pela LAN além de 80 (e 443 quando
HTTPS for configurado — ver `docs/OPERATIONS.md`).
## Instalação manual (passo a passo, se preferir não usar o script)
Ver o conteúdo de `scripts/install.sh` — cada etapa pode ser executada
isoladamente. Pontos que merecem atenção especial se for fazer manualmente:
- **Ordem de subida**: Postgres/Redis saudáveis → migrations → Asterisk →
demais serviços. Subir tudo de uma vez com `docker compose up -d` também
funciona (o `depends_on: condition: service_healthy` do
`docker-compose.yml` já orquestra isso), mas as migrations/seed do Prisma
precisam rodar manualmente contra o Postgres antes da API funcionar de
verdade (a API não roda migrations automaticamente no boot — decisão
deliberada para nunca alterar schema de produção sem um passo explícito).
- **DATABASE_URL para comandos rodados do host** (fora dos containers):
use `127.0.0.1` como host, não `postgres` — ver
`docs/ARCHITECTURE.md` §3.2.1.
## Atualizando uma instalação existente
```bash
sudo ./scripts/update.sh
```
Ver `docs/OPERATIONS.md` para detalhes.

51
docs/OPENSIPS.md Normal file
View File

@@ -0,0 +1,51 @@
# B2BCall — OpenSIPS (não implementado nesta fase)
A arquitetura alvo do projeto (agente.md seção 5) prevê OpenSIPS na frente
do Asterisk:
```text
INTERNET → [OpenSIPS] → rede SIP privada → [Asterisk] → [B2BCall]
```
**Este componente não foi implantado neste ciclo.** O Asterisk hoje recebe
tráfego SIP diretamente (protegido por nftables, sem IP público — ver
`docs/ARCHITECTURE.md` §3.2). Isso é suficiente para o ambiente de
laboratório atual (um servidor, troncos configurados diretamente no
Asterisk), mas não entrega os benefícios que OpenSIPS traria:
- Balanceamento entre múltiplas instâncias de Asterisk.
- Roteamento/normalização de múltiplos troncos antes de chegar ao core de
telefonia.
- Uma camada adicional de proteção SIP (rate limiting, topology hiding)
antes do Asterisk.
## Por que não foi feito agora
Motivo honesto: com um único servidor e um único Asterisk, OpenSIPS não
resolve nenhum problema real deste ambiente — adicionaria complexidade
operacional (mais um serviço com seu próprio config/estado/observabilidade)
sem benefício imediato. A decisão de projeto (agente.md seções 71/72) é não
construir infraestrutura para um cenário hipotético que ainda não existe.
## Quando reconsiderar
Implantar OpenSIPS quando pelo menos uma destas condições existir:
1. Mais de uma instância de Asterisk precisar compartilhar o mesmo conjunto
de troncos/roteamento.
2. Necessidade de expor SIP a múltiplas operadoras com normalização de
roteamento antes do Asterisk.
3. Volume que justifique uma camada de proteção SIP dedicada, separada do
Asterisk.
## Esboço de integração futura
- OpenSIPS ficaria na mesma rede privada do Asterisk (nunca IP público
direto no Asterisk — isso não muda).
- Trunks/roteamento no domínio da aplicação (`Trunk` no Postgres) já
modelam host/porta/credenciais de forma genérica — o registro de
domínios `Trunk.host` como sendo o OpenSIPS em vez do Asterisk-tronco
direto é, na prática, uma mudança de configuração, não de schema.
- Regras de firewall (`infrastructure/nftables/`) precisariam abrir a porta
SIP do OpenSIPS para a origem confiável (operadora/OpenSIPS peer),
mantendo o Asterisk inacessível de fora da rede interna.

78
docs/OPERATIONS.md Normal file
View File

@@ -0,0 +1,78 @@
# B2BCall — Operação
## Comandos do dia a dia
| Tarefa | Comando |
|---|---|
| Ver status de tudo | `bash scripts/healthcheck.sh` |
| Ver logs de um serviço | `docker compose logs -f api` (troque `api` pelo serviço) |
| Reiniciar um serviço | `docker compose restart api` |
| Aplicar atualização de código | `sudo ./scripts/update.sh` |
| Backup manual | `sudo ./scripts/backup.sh` |
| Restaurar backup | `sudo ./scripts/restore.sh backups/postgres-<ts>.dump` |
| Console do Asterisk | `docker exec -it b2bcall-asterisk asterisk -rvvv` |
| Ver regras de firewall ativas | `nft list table inet b2bcall_fw` |
## Atualização (`scripts/update.sh`)
1. `git pull --ff-only` (se for um clone git).
2. `pnpm install --frozen-lockfile`.
3. `prisma migrate deploy` contra o Postgres (via `127.0.0.1:5432`, fora dos
containers).
4. `docker compose build` — rebuilda só as imagens cujo contexto mudou
(cache do Docker layer a layer).
5. `docker compose up -d` — recria containers com imagem nova.
6. `scripts/healthcheck.sh`.
Não há downtime zero: durante o rebuild/restart de `api`/`frontend`/`nginx`
há uma janela curta de indisponibilidade (segundos). O Asterisk e as
chamadas em andamento não são afetados a menos que a migration exija
mudança incompatível em tabelas usadas pelo Realtime (raro — revisar a
migration antes de rodar em produção com campanhas ativas).
## Rotina recomendada de produção
- **Backup diário** via cron (`docs/BACKUP_RESTORE.md`).
- **Monitoramento**: apontar Prometheus para `http://api:3000/api/metrics`
de dentro da rede Docker (`b2bcall-net`) — o endpoint exige uma sessão
autenticada (permissão `monitoring.view`) e não é exposto via Nginx, então
um scraper externo precisaria de uma integração dedicada (fora do escopo
atual; hoje o uso pretendido é observabilidade interna/manual).
- **Alertas mínimos recomendados**: `b2bcall_calls_abandoned_total` /
`b2bcall_calls_total` acima do limiar de compliance (ver
`GET /api/compliance/indicators`, que já calcula isso), containers não
saudáveis (`docker compose ps`), espaço em disco (dumps + logs do
Asterisk).
- **Rotação de logs do Asterisk**: `/var/log/asterisk` dentro do volume
`asterisk-log` cresce indefinidamente sem `logrotate` configurado — não
incluído nesta fase (ver `TODO.md`, limitação conhecida).
## Escalando além do laboratório atual
O ambiente original (1.9 GiB RAM / 2 vCPU) é suficiente para desenvolvimento
e para o modo `DIALER_SIMULATION=true`, não para volume real de discagem
(`docs/ARCHITECTURE.md` §1). Para produção real:
1. Aumentar para pelo menos 4 vCPU / 8 GiB RAM.
2. Revisar `mem_limit` de cada serviço em `docker-compose.yml` (hoje
conservador para caber em 1.9 GiB).
3. Considerar mover Postgres/Redis para fora do mesmo host do Asterisk se o
volume de CDR/CEL crescer muito (arquitetura já separa os dois
logicamente — schema `asterisk` dedicado — o que facilita migrar depois).
4. Colocar OpenSIPS na frente do Asterisk para múltiplos troncos/roteamento
avançado (não implementado nesta fase — ver `docs/OPENSIPS.md`).
## HTTPS
Não configurado nesta fase (a rede é privada, sem IP público — seção 5).
Para expor com TLS:
1. Obter certificado (Let's Encrypt via DNS challenge, já que não há IP
público para HTTP-01, ou certificado interno/self-signed para uso só na
LAN).
2. Editar `infrastructure/nginx/nginx.conf`: adicionar bloco
`listen 443 ssl;` com `ssl_certificate`/`ssl_certificate_key`, e um
bloco `listen 80` que só redireciona para 443.
3. Publicar a porta 443 em `docker-compose.yml` (serviço `nginx`).
4. Atualizar `infrastructure/nftables/` se necessário (443 já não é
bloqueado por padrão, só os serviços administrativos do Asterisk são).

157
docs/RELATORIO_FINAL.md Normal file
View File

@@ -0,0 +1,157 @@
# B2BCall — Relatório Final da Implementação
Formato conforme agente.md seção 96. Gerado em 2026-08-27 contra o ambiente
real em execução (`Lab-FSCore-API`, 10.10.32.142).
```text
B2BCall version: 0.1.0
Asterisk version: 22.10.1
Node version: v24.20.0
PostgreSQL version: 17 (postgres:17-alpine)
Docker version: 29.7.2 (Compose v5.5.0)
```
## Containers
| Container | Status |
|---|---|
| b2bcall-postgres | healthy |
| b2bcall-redis | healthy |
| b2bcall-asterisk | healthy |
| b2bcall-api | healthy |
| b2bcall-frontend | healthy |
| b2bcall-nginx | healthy |
| b2bcall-asterisk-events | up (sem healthcheck HTTP formal — worker sem porta exposta) |
| b2bcall-dialer-worker | up (idem) |
## URLs
- Interface web: `http://10.10.32.142/`
- API: `http://10.10.32.142/api`
- Health: `http://10.10.32.142/api/health`
- Métricas (Prometheus, autenticado, uso interno): `http://10.10.32.142/api/metrics`
## Super admin
Ver arquivo local `CREDENCIAIS.txt` (fora do git, entregue separadamente —
nunca colocamos senha em texto puro em um arquivo versionado, mesmo em um
Git privado). Contém: usuário/senha do `super_admin` web e as credenciais
do PostgreSQL/Redis.
## Health
```text
$ bash scripts/healthcheck.sh
docker compose ps: 8/8 containers up
/api/health: {"status":"ok","postgres":"up","redis":"up","asterisk":"up"}
Asterisk core show uptime: OK
pjsip show endpoints: OK (comando responde; sem endpoints — fixtures de teste removidos)
nftables (b2bcall_fw): ativo
```
### Aceite Asterisk (seção 93)
```text
$ asterisk -rx "core show version"
Asterisk 22.10.1 built by root @ buildkitsandbox on a x86_64 running Linux on 2026-08-27 13:32:29 UTC
$ asterisk -rx "core show uptime"
System uptime: 5 hours, 43 minutes, 19 seconds
Last reload: 6 minutes, 54 seconds
$ asterisk -rx "pjsip show endpoints"
No objects found.
(esperado: todos os troncos/ramais de teste foram removidos ao final de cada fase)
$ asterisk -rx "pjsip show contacts"
No objects found.
$ asterisk -rx "queue show"
No queues.
```
**Comunicação API → AMI → Asterisk**: validada via
`GET /api/asterisk/status`
`{"amiControlConnection":"up","asteriskEventsHeartbeat":"up",...}`.
## Tests
```text
Build/typecheck (pnpm -r build, 7 workspaces): PASS
Lint (api, frontend): PASS
Unit tests:
apps/api 13/13 PASS
apps/dialer-worker 33/33 PASS
docker compose config: válida
docker compose ps: 8/8 up
scripts/healthcheck.sh: PASS
```
### Aceite de segurança (seção 92) — verificado ao vivo, não só por inspeção
| Item | Resultado |
|---|---|
| Nenhuma senha no Git | ✅ (`git log --all -- .env` vazio) |
| Nenhum `.env` commitado | ✅ |
| PostgreSQL não público | ✅ (`127.0.0.1:5432` apenas) |
| Redis não público | ✅ (nenhuma porta publicada) |
| AMI não público | ✅ (nftables bloqueia `ens18:5038`) |
| ARI não público | ✅ (nftables bloqueia `ens18:8088/8089`) |
| Rate limit funcionando | ✅ (7ª tentativa de login seguida → HTTP 429) |
| RBAC funcionando no backend | ✅ (papel `agent` → 403 em `/asterisk/status`, `/trunks`, `/users`) |
| Agent não acessa configuração do Asterisk | ✅ |
| Agent não eleva a própria permissão | ✅ |
| SQL injection protegida | ✅ (Prisma parametrizado em toda parte) |
| Path traversal protegido | ✅ (nenhum path de arquivo construído a partir de input do usuário) |
| Logs sem credenciais | ✅ (redação do pino confirmada, varredura real sem vazamento) |
### Prova do dialer (seção 91)
- CPS configurado nunca excedido em nenhuma janela de 1s: ✅ (`simulation-harness.spec.ts`)
- Concorrência máxima da campanha nunca excedida: ✅ (idem)
- Pacing reage/reduz quando abandono sobe: ✅ (idem + bug real corrigido durante o desenvolvimento, ver `docs/PREDICTIVE_DIALER.md`)
- Reprodutibilidade (mesma seed → mesmo resultado): ✅
- Reserva de lead atômica (sem discagem dupla): ✅ (`FOR UPDATE SKIP LOCKED`, testado na Fase 6 contra Postgres real)
- Campanha pausada não gera chamada nova: ✅ (testado na Fase 6)
- Lead suprimido não é discado: ✅ (`isSuppressed` pré-originação, testado na Fase 6)
- Campanha fora do horário não discou: ✅ (`schedule.spec.ts`, 6 testes)
- Worker reiniciado recupera operação: ⚠️ parcial — `reconciliation.ts` tem teste unitário e roda em produção a cada 60s, mas o cenário "matar o processo de verdade no meio de uma chamada" não foi exercitado nesta sessão (ver Pending).
## Pending
Itens genuinamente pendentes, nunca escondidos:
1. **Integration tests automatizados** (Postgres/Redis/AMI reais via
testcontainers ou equivalente) não existem como suíte formal — toda a
verificação de integração foi feita manualmente contra containers reais
a cada fase (real, mas não repetível em CI sem intervenção).
2. **Recuperação após kill real do worker** não foi testada nesta sessão
(só via teste unitário da lógica de reconciliação).
3. **`scripts/install.sh`/`update.sh`** não foram testados de ponta a ponta
num servidor limpo (o servidor atual já estava provisionado) — validados
por inspeção linha a linha contra os comandos manuais já executados.
4. **Verificação visual em navegador real** não foi feita (ambiente
headless) — validado via `curl` reproduzindo as chamadas do navegador.
5. **Tela de Callbacks** no frontend não existe — o agendamento e a
reativação automática funcionam via API/worker (`POST /agent-console/
dispose`, `GET /api/callbacks`, `callback-sweep.ts`), mas não há uma
tela dedicada para visualizar a fila de callbacks pendentes.
6. **AMD** (detecção de secretária eletrônica) — campo existe no schema,
detecção real via app AMD do Asterisk não integrada à originação.
7. **Correlação automática CDR↔DialAttempt** para chamadas reais (não
simuladas) além do timeout de segurança de reconciliação.
8. **WebSocket real** — painéis de monitoramento usam polling
(5-15s), não um gateway WebSocket dedicado.
9. **OpenSIPS** não implantado — decisão documentada em `docs/OPENSIPS.md`
(não resolve nenhum problema real do ambiente atual de instância única).
10. **HTTPS** não configurado (rede privada, sem IP público) — passo a
passo em `docs/OPERATIONS.md`.
11. `asterisk-events`/`dialer-worker` não têm healthcheck Docker formal
(workers em background sem porta HTTP própria).
12. Rotação de `SECRETS_MASTER_KEY` não é automatizada — exigiria
redescriptografar todos os segredos de tronco/ramal manualmente.
Nenhum destes itens bloqueia o uso do sistema para o que foi pedido
(discador preditivo funcional, call center completo, frontend completo,
segurança e backup); são lacunas de robustez/automação a considerar antes
de operar com volume real de produção.

112
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,112 @@
# B2BCall — Segurança
## Autenticação
- Senhas com **Argon2id** (`argon2` lib), nunca bcrypt/md5/sha.
- JWT de acesso (curta duração, `JWT_ACCESS_TTL`) + refresh token opaco
(hash SHA-256 armazenado, nunca o token em texto puro) com **rotação**: a
cada uso, o refresh antigo é revogado e um novo par é emitido.
- Cookies `HttpOnly`, `SameSite=Lax`, `Secure` quando HTTPS estiver ativo.
`SameSite=Lax` é a mitigação primária de CSRF (o navegador não envia o
cookie em requisições cross-site que não sejam navegação de topo) — não
há token CSRF de double-submit adicional. Suficiente para o modelo de
ameaça atual (rede privada, sem terceiros hospedando conteúdo que chame a
API); reavaliar se o frontend algum dia rodar em uma origem
publicamente diferente da API.
- Rate limiting progressivo de login por IP via Redis
(`LoginThrottleService`): backoff exponencial a cada bloco de tentativas
falhas, nunca bloqueio permanente sem reset.
- Resposta de "esqueci minha senha" sempre genérica, independente de o
e-mail existir (evita enumeração de usuários).
- Troca de senha revoga todas as sessões ativas do usuário.
## Autorização (RBAC)
- `User``UserRole``Role``RolePermission``Permission`.
- Catálogo de permissões centralizado (`packages/shared/src/permissions.ts`)
— nenhuma checagem de permissão usa string solta espalhada pelo código.
- `PermissionsGuard` global, aplicado via `@RequirePermissions(...)` em
cada rota que precisa (rotas sem decorator exigem apenas autenticação).
- **Ninguém pode alterar os próprios perfis de acesso**
(`users.service.ts#update`), mesmo tendo permissão de gerenciar usuários
— bloqueia auto-elevação mesmo por um super_admin comprometido/mal
configurado.
## Criptografia de segredos
- `Trunk.secretEncrypted` / `Extension.sipPasswordEncrypted`: AES-256-GCM
(`packages/shared/src/secret-crypto.ts`), chave mestra
`SECRETS_MASTER_KEY` (`.env`, nunca no banco, nunca versionada).
- Nenhum segredo (senha de tronco, senha de ramal, tokens) é retornado em
`GET`/`PATCH` — só uma vez, no momento da criação, quando aplicável.
## Auditoria
- `AuditService` registra toda ação sensível (login, criação/edição/exclusão
de entidades, disposição de chamada, mudanças de compliance, etc.) com
`userId`, `ipAddress`, `userAgent`, estado antes/depois.
- Redação automática de campos sensíveis (`SENSITIVE_KEYS`) antes de
persistir `before`/`after` — senha, secret, token nunca aparecem em texto
puro no log de auditoria.
- Logs estruturados JSON (`nestjs-pino`/`pino`) com `request_id` de
correlação — mesma política de redação se aplica.
## Rede
- Único ponto exposto à LAN: Nginx, porta 80 (443 quando HTTPS configurado
`docs/OPERATIONS.md`).
- Postgres/Redis nunca publicados fora de `127.0.0.1`/rede Docker interna.
- AMI (5038), ARI (8088/8089), SIP (5060/5061), RTP (10000-20000) bloqueados
da interface LAN por nftables (`infrastructure/nftables/b2bcall.nft`),
mesmo o Asterisk rodando em `network_mode: host`. SSH nunca bloqueado.
- CORS: `origin` restrito a uma allowlist configurável (`ALLOWED_ORIGINS`),
`credentials: true` só para essas origens.
- Helmet (`@fastify/helmet`) ativo: `X-Content-Type-Options`,
`X-Frame-Options`, `Strict-Transport-Security`,
`Cross-Origin-Opener-Policy`, etc.
## Validação de entrada
- Todo DTO usa `class-validator` (`ValidationPipe` global,
`whitelist: true, forbidNonWhitelisted: true`) — payload com campo não
esperado é rejeitado, não ignorado silenciosamente.
- Toda query ao banco via Prisma (parametrizado) — as únicas duas exceções
(`$queryRaw` em `lead-repository.ts` e `agent-call-binding.ts`) usam
apenas parâmetros tipados interpolados pelo próprio Prisma
(`$queryRaw\`...${valor}...\``), nunca concatenação de string.
- Comandos de diagnóstico do Asterisk expostos via API usam **allowlist
explícita** de comandos (`GET /api/asterisk/diagnostic/allowed-commands`)
— nunca shell livre nem `asterisk -rx "<input do usuário>"` sem filtro.
## Checklist de aceite de segurança (agente.md seção 92)
| Item | Status |
|---|---|
| Senhas com hash forte (Argon2id) | ✅ |
| Nenhuma senha padrão (bootstrap sempre aleatório) | ✅ |
| RBAC aplicado no backend (não só escondido na UI) | ✅ |
| Agente não eleva a própria permissão | ✅ |
| Segredos de tronco/ramal cifrados em repouso | ✅ |
| Postgres/Redis não expostos à LAN | ✅ |
| AMI/ARI/SIP não expostos à LAN | ✅ (nftables) |
| Rate limiting de login | ✅ |
| Auditoria de ações sensíveis, sem vazar segredos | ✅ |
| Logs estruturados sem credenciais | ✅ |
| `.env`/segredos nunca versionados no git | ✅ |
| Validação de entrada em todos os DTOs | ✅ |
| Sem SQL injection (Prisma parametrizado em toda parte) | ✅ |
| Sem shell injection (allowlist de comandos Asterisk) | ✅ |
| CSRF | ⚠️ Mitigado via `SameSite=Lax`, sem token dedicado (ver acima) |
| HTTPS | ⚠️ Não configurado nesta fase (rede privada) — ver `docs/OPERATIONS.md` |
## Como reportar/tratar um incidente
1. Revogar sessões: `UPDATE sessions SET revoked_at = now() WHERE revoked_at IS NULL;`
(ou endpoint de logout em massa, se necessário adicionar).
2. Rotacionar segredos: gerar novos valores com
`scripts/generate-secrets.sh`, atualizar `.env`, reiniciar `api`.
Rotacionar `SECRETS_MASTER_KEY` exige redescriptografar e recriptografar
todos os `Trunk.secretEncrypted`/`Extension.sipPasswordEncrypted` (não
automatizado nesta fase — script futuro se necessário).
3. Consultar `AuditLog` filtrando por `userId`/`entityType`/janela de tempo
para reconstituir o que aconteceu.

108
docs/TROUBLESHOOTING.md Normal file
View File

@@ -0,0 +1,108 @@
# B2BCall — Troubleshooting
Problemas reais encontrados durante o desenvolvimento deste projeto e como
foram diagnosticados/resolvidos — mantido como referência para o futuro.
## "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.