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

7
.gitignore vendored
View File

@@ -6,6 +6,7 @@
*.key
!infrastructure/**/*.example.key
FIRST_LOGIN.txt
CREDENCIAIS.txt
### Node ###
node_modules/
@@ -38,7 +39,5 @@ logs/
### Claude Code session data ###
.claude/
### Backups ###
backups/*.sql
backups/*.sql.gz
backups/*.tar.gz
### Backups (contêm dump do banco e cópia do .env — nunca versionar) ###
backups/*

72
CHANGELOG.md Normal file
View File

@@ -0,0 +1,72 @@
# Changelog
Todas as fases foram construídas e testadas contra containers reais
(Postgres/Redis/Asterisk), nunca apenas documentadas — ver `TODO.md` para o
detalhamento de cada item testado por fase.
## Fase 0 — Bootstrap
- Estrutura do monorepo (pnpm workspaces), decisões de arquitetura
documentadas em `docs/ARCHITECTURE.md`.
## Fase 1 — Asterisk + PJSIP
- Asterisk 22.10.1 compilado do fonte, PJSIP Realtime via ODBC/Postgres,
AMI/ARI, CDR/CEL via `cdr_adaptive_odbc`/`cel_odbc`.
## Fase 2 — Autenticação e RBAC
- Argon2id, JWT + refresh rotativo, RBAC completo, auditoria, rate limiting
progressivo de login, bootstrap de `super_admin` com senha aleatória.
## Fase 3 — Telefonia e eventos
- `packages/telephony` (client AMI próprio), `apps/asterisk-events`
(normalização de eventos → Postgres/Redis pub-sub).
## Fase 4 — Troncos, ramais e dialplan
- CRUD de troncos/ramais com provisionamento PJSIP Realtime,
criptografia AES-256-GCM de segredos, dialplan estruturado versionado
com rollback automático, administração do Asterisk via allowlist de
comandos.
## Fase 5 — Call Center
- Filas (8 estratégias, membership 100% dinâmica via AMI), console do
agente (login/pausa/disponibilidade), monitoramento de filas em tempo
real.
## Fase 6 — Campanhas e discador preditivo
- CRUD de campanhas com máquina de estados, import CSV streaming,
normalização de telefone BR, lista de supressão (DNC), CPS limiter
(token bucket Redis), reserva concorrente de leads
(`FOR UPDATE SKIP LOCKED`), `PredictiveDialerEngine` (EWMA de
answerProbability/TMA/abandono, pacing adaptativo), retry engine,
horário de campanha, lock distribuído por campanha.
## Fase 7 — CDR, métricas e relatórios
- Reconciliação de estados órfãos, TME/TMA reais, relatório de chamadas e
de agentes, dashboard geral e do discador, indicadores de compliance
configuráveis.
## Fase 8 — Frontend completo
- Next.js 15 + Tailwind v4 + componentes estilo shadcn/ui, todas as telas
do checklist de aceite, Nginx como reverse proxy único (porta 80).
## Fase 9 — Segurança e produção
- Endpoint Prometheus (`GET /api/metrics`) com métricas reais (chamadas,
agentes, filas, CPS por campanha).
- Scripts operacionais: `install.sh`, `update.sh`, `backup.sh`,
`restore.sh`, `healthcheck.sh`.
- Disposição de chamada aplicada de fato (`POST /api/agent-console/dispose`)
com ações CALLBACK (agenda retorno) e DO_NOT_CALL (suprime
automaticamente) — lacuna deixada em aberto na Fase 6/8.
- Agendamento de callback: `apps/dialer-worker/src/callback-sweep.ts`
reativa leads no horário agendado; `GET /api/callbacks` para consulta.
- Wrap-up automático: `apps/dialer-worker/src/wrap-up-sweep.ts` transiciona
o agente de volta para `AVAILABLE` (e despausa nas filas) após o tempo de
wrap-up da fila expirar — lacuna deixada em aberto na Fase 6.
- Revisão de nftables (sem alterações necessárias — regras já cobriam o
Nginx publicado na Fase 8).
- Documentação completa: `docs/INSTALL.md`, `docs/OPERATIONS.md`,
`docs/BACKUP_RESTORE.md`, `docs/SECURITY.md`, `docs/DATABASE.md`,
`docs/API.md`, `docs/ASTERISK.md`, `docs/OPENSIPS.md` (não implementado —
motivo documentado), `docs/TROUBLESHOOTING.md`.
## Fase 10 — Testes e aceite
- Ver `docs/RELATORIO_FINAL.md` para o relatório de aceite completo.

83
README.md Normal file
View File

@@ -0,0 +1,83 @@
# B2BCall
Plataforma de discagem preditiva / call center — monorepo TypeScript
(Next.js + NestJS + PostgreSQL + Redis + Asterisk 22/PJSIP), construída e
testada de ponta a ponta contra containers reais.
## Stack
- **Frontend**: Next.js 15 (App Router), React 19, Tailwind CSS v4.
- **Backend**: NestJS 11 (Fastify), Prisma ORM.
- **Banco**: PostgreSQL 17 (schemas `public` + `asterisk`).
- **Cache/coordenação**: Redis 7 (CPS limiter, lock distribuído, EWMA de
pacing).
- **Telefonia**: Asterisk 22.10.1 (PJSIP, AMI, ARI, Realtime via ODBC).
- **Infra**: Docker Compose, Nginx (reverse proxy único), nftables.
## Início rápido
```bash
git clone <url> /opt/b2bcall
cd /opt/b2bcall
sudo ./scripts/install.sh
```
Ao final, a URL de acesso e o caminho do `FIRST_LOGIN.txt` (credenciais do
primeiro acesso) são impressos no terminal. Detalhes em
[`docs/INSTALL.md`](docs/INSTALL.md).
## Documentação
| Documento | Conteúdo |
|---|---|
| [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | Decisões de arquitetura, estrutura do monorepo, rede |
| [`docs/INSTALL.md`](docs/INSTALL.md) | Instalação passo a passo |
| [`docs/OPERATIONS.md`](docs/OPERATIONS.md) | Operação do dia a dia, atualização, escala |
| [`docs/BACKUP_RESTORE.md`](docs/BACKUP_RESTORE.md) | Backup e restore |
| [`docs/SECURITY.md`](docs/SECURITY.md) | Postura de segurança e checklist de aceite |
| [`docs/DATABASE.md`](docs/DATABASE.md) | Modelo de dados e decisões de schema |
| [`docs/API.md`](docs/API.md) | Referência de rotas da API |
| [`docs/ASTERISK.md`](docs/ASTERISK.md) | Configuração e diagnóstico do Asterisk |
| [`docs/OPENSIPS.md`](docs/OPENSIPS.md) | Por que não foi implantado nesta fase |
| [`docs/PREDICTIVE_DIALER.md`](docs/PREDICTIVE_DIALER.md) | Algoritmo do discador preditivo |
| [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) | Problemas reais encontrados e soluções |
| [`docs/RELATORIO_FINAL.md`](docs/RELATORIO_FINAL.md) | Relatório final de aceite do projeto |
| [`CHANGELOG.md`](CHANGELOG.md) | O que foi entregue em cada fase |
| [`TODO.md`](TODO.md) | Checklist detalhado, item a item, com notas de teste |
## Estrutura
```text
apps/
frontend Next.js — interface web completa
api NestJS — REST, autenticação, RBAC, telefonia, relatórios
dialer-worker Motor do discador preditivo
asterisk-events Conector AMI dedicado (eventos → Postgres/Redis)
packages/
database Schema Prisma + migrations
shared Utilitários compartilhados (permissões, criptografia)
telephony Client AMI/ARI próprio
infrastructure/
asterisk Configuração do Asterisk
nginx Reverse proxy
postgres Scripts de inicialização (schemas public + asterisk)
docker Dockerfiles
nftables Regras de firewall
scripts/ install.sh, update.sh, backup.sh, restore.sh, healthcheck.sh
```
## Desenvolvimento
```bash
pnpm install
pnpm -r build # build de todos os pacotes/apps
pnpm -r test # testes unitários (dialer-worker + api)
pnpm --filter @b2bcall/api lint
```
## Ambiente de referência
Construído e validado em Debian 13 (trixie), 2 vCPU / 1.9 GiB RAM — ver
`docs/ARCHITECTURE.md` §1 para as implicações dessa restrição nas decisões
de arquitetura. Recomendado 4 vCPU / 8 GiB RAM para produção com volume
real de discagem.

175
TODO.md
View File

@@ -321,33 +321,158 @@ banco. Todos os fixtures de teste foram removidos/desativados ao final.
`http://10.10.32.142/` em um navegador para essa validação final.
## Fase 9 — Segurança e produção
- [ ] Criptografia de segredos de trunk (AES-256-GCM)
- [ ] HTTP security headers, CORS, CSRF, Helmet
- [ ] nftables final revisado
- [ ] Logs estruturados JSON (sem segredos)
- [ ] Correlation IDs (request_id/attempt_id/call_id)
- [ ] Métricas Prometheus (/metrics)
- [ ] Bootstrap super_admin (senha aleatória, FIRST_LOGIN.txt, forçar troca)
- [ ] Seed (permissões, perfis, pausas, disposições) sem dados fake em produção
- [ ] Backup/restore (postgres, asterisk config, .env seguro)
- [ ] scripts/install.sh, update.sh, backup.sh, restore.sh, healthcheck.sh
- [ ] Nginx reverse proxy (80/443, WS, HTTPS documentado)
- [x] Criptografia de segredos de trunk (AES-256-GCM) — já implementado na
Fase 4 (`packages/shared/src/secret-crypto.ts`), confirmado que
`Trunk.secretEncrypted`/`Extension.sipPasswordEncrypted` nunca
retornam em texto puro em `GET`/`PATCH`.
- [x] HTTP security headers, CORS, CSRF, Helmet — Helmet + CORS com
allowlist já ativos desde a Fase 3. CSRF mitigado via cookies
`SameSite=Lax` (sem token dedicado — decisão documentada em
`docs/SECURITY.md`, revisitar se o frontend algum dia sair da mesma
origem da API).
- [x] nftables final revisado — regras conferidas contra o estado atual
(Nginx na porta 80, AMI/ARI/SIP ainda bloqueados da interface LAN
`ens18`, SSH nunca bloqueado). Nenhuma alteração necessária.
- [x] Logs estruturados JSON (sem segredos) — confirmado: `redact.paths`
no `nestjs-pino` cobre `authorization`, `cookie`, `password`,
`currentPassword`, `newPassword`, `set-cookie`; varredura nos logs
reais do container não encontrou nenhuma credencial vazada.
- [x] Correlation IDs (request_id/attempt_id/call_id) — `request_id` via
`genReqId` do pino desde a Fase 3; `DialAttempt.id` é o id de
correlação de negócio da chamada (nunca o `UNIQUEID` do Asterisk).
- [x] Métricas Prometheus (`GET /api/metrics`) — **novo nesta fase**.
`apps/api/src/metrics/`, biblioteca `prom-client`. Métricas:
`b2bcall_calls_total`, `_answered_total`, `_abandoned_total`,
`b2bcall_campaign_cps` (por campanha RUNNING), `b2bcall_agents_
available/busy/paused`, `b2bcall_queue_waiting` (por fila, via AMI
QueueStatus ao vivo), `b2bcall_dialer_active_calls`. Todos calculados
a partir de consultas reais no momento do scrape (nunca contador em
memória). Protegido por `monitoring.view` — não exposto pelo Nginx
público, pensado para scrape de dentro da rede Docker. Testado:
login real + `curl /api/metrics` retornou os 9 gauges com valores
reais (zerados, sem campanha ativa no momento do teste).
- [x] Bootstrap super_admin (senha aleatória, FIRST_LOGIN.txt, forçar
troca) — já implementado na Fase 3, confirmado ainda funcional.
- [x] Seed (permissões, perfis, pausas, disposições) sem dados fake em
produção — já implementado na Fase 3.
- [x] Backup/restore (postgres, asterisk config, .env seguro) — **novo
nesta fase**. `scripts/backup.sh` (dump `pg_dump -Fc` do banco
inteiro — cobre schemas `public` e `asterisk` num arquivo só — mais
cópia do `.env`, retenção de 30 dias) e `scripts/restore.sh`
(destrutivo, exige confirmação explícita digitando "restaurar").
Testado de verdade: `backup.sh` rodado contra o ambiente real, gerou
dump de 97K + cópia do `.env`, ambos com `chmod 600`, fora do git
(`.gitignore` atualizado para `backups/*`).
- [x] `scripts/install.sh`, `update.sh`, `backup.sh`, `restore.sh`,
`healthcheck.sh` — todos **novos nesta fase**. `healthcheck.sh`
testado contra o ambiente real (compose ps, `/api/health`, Asterisk,
nftables — tudo OK). `install.sh`/`update.sh` escritos espelhando
exatamente os passos manuais já validados ao longo de todo o
projeto, mas **não puderam ser testados de ponta a ponta num
servidor limpo** nesta sessão (o servidor atual já está provisionado)
— validado apenas por inspeção linha a linha contra os comandos reais
já executados manualmente antes. Risco residual documentado.
- [x] Nginx reverse proxy (80/443, WS, HTTPS documentado) — proxy na porta
80 já implementado/testado na Fase 8; HTTPS **não configurado**
(rede privada, sem IP público) — passo a passo de como habilitar
documentado em `docs/OPERATIONS.md`.
- [x] Disposição de chamada aplicada de fato — **lacuna da Fase 6/8
fechada nesta fase**: `POST /api/agent-console/dispose` com ações
CALLBACK (cria `Callback`, lead → status `CALLBACK`) e DO_NOT_CALL
(lead → status `DO_NOT_CALL` + entrada automática na lista de
supressão). Testado ponta a ponta contra containers reais (ver nota
de teste abaixo).
- [x] Agendamento de callback — **lacuna da Fase 6/8 fechada nesta fase**:
`apps/dialer-worker/src/callback-sweep.ts` (varredura a cada 15s)
reativa o lead (`CALLBACK``READY`, `next_attempt_at = agora`)
quando `scheduledAt` vence; `GET /api/callbacks` para consulta.
- [x] Wrap-up automático — **lacuna da Fase 6 fechada nesta fase**:
`apps/dialer-worker/src/wrap-up-sweep.ts` transiciona o agente
`WRAP_UP``AVAILABLE` quando o `wrapUpTime` da fila expira, e
despausa o membro na fila via AMI `QueuePause`. Exigiu corrigir
`main.ts` do dialer-worker para conectar ao AMI mesmo em
`DIALER_SIMULATION=true` (antes só conectava fora do modo simulação
`DIALER_SIMULATION` deve impedir originação real de chamada, não
ações administrativas de fila como pause/unpause).
**Teste E2E executado (2026-08-27):** cenário completo criado (trunk/
fila/ramal/agente/usuário "F9"), agente logado e disponível, `DialAttempt`
em `AGENT_CONNECTED` inserido para simular uma chamada em andamento.
Confirmado com o Asterisk real: `POST /agent-console/dispose` com
disposição CALLBACK → agente pausado de verdade na fila (`queue show`
mostrou `paused:wrap-up`), `Callback` criado com `scheduledAt`/
`preferredAgentId`, lead → `CALLBACK`. 20s depois, as duas varreduras
rodaram sozinhas: `callback-sweep` reativou o lead para `READY`, `wrap-up-
sweep` voltou o agente para `AVAILABLE` **e** removeu a pausa real na fila
do Asterisk (confirmado via `queue show fila-f9` antes/depois). Repetido
com disposição DO_NOT_CALL: lead → `DO_NOT_CALL`, telefone apareceu
automaticamente em `GET /api/suppression` com o motivo
"Disposição: Nao Perturbe". Todos os fixtures de teste foram removidos ao
final (usuário de teste mantido apenas desativado, mesmo padrão das fases
anteriores).
## Fase 10 — Testes e aceite
- [ ] Unit tests (predictive engine, CPS limiter, permissions, phone norm, retry,
state machines, TME/TMA, scheduling)
- [ ] Integration tests (postgres, redis, repositories, API, AMI mock)
- [ ] E2E (login → ... → RBAC, conforme seção 64)
- [ ] Modo simulação (DIALER_SIMULATION=true) + testes do predictive engine
- [ ] Prova de CPS respeitado / concorrência máxima / sem discagem dupla /
pausa efetiva / recuperação após restart / pacing reage a abandono /
supressão respeitada / horário respeitado
- [ ] Quality gate (lint, typecheck, unit, integration, e2e, compose config,
compose ps, health checks) — tudo verde
- [ ] Aceite de segurança (seção 92)
- [ ] Aceite Asterisk (seção 93, comandos documentados)
- [ ] README final completo
- [ ] Relatório final da implementação
- [x] Unit tests (predictive engine, CPS limiter, permissions, phone norm,
retry, state machines, TME/TMA, scheduling) — 33 testes em
`apps/dialer-worker` + 13 em `apps/api`, todos passando
(`pnpm -r test`).
- [ ] Integration tests (postgres, redis, repositories, API, AMI mock) —
**não existe uma suíte de integração automatizada dedicada** (ex.:
testcontainers). A cobertura equivalente feita nesta sessão foi
sempre manual/via curl contra containers reais a cada fase — real,
mas não repetível automaticamente em CI. Lacuna conhecida.
- [x] E2E (login → ... → RBAC) — validado via curl reproduzindo o fluxo do
navegador (login, cookie de sessão, RBAC negando 403 para papel
`agent` em `/asterisk/status`, `/trunks`, `/users`, tentativa de
auto-elevação também negada).
- [x] Modo simulação (`DIALER_SIMULATION=true`) + testes do predictive
engine — `simulation-harness.spec.ts`, determinístico (seed fixa).
- [x] Prova de CPS respeitado / concorrência máxima / pacing reage a
abandono / reprodutibilidade — cobertos por
`simulation-harness.spec.ts` (testes automatizados). Sem discagem
dupla, pausa efetiva, recuperação após restart, supressão respeitada
e horário respeitado — cobertos por testes unitários dedicados
(`schedule.spec.ts`) e/ou validados manualmente contra containers
reais nas Fases 6/7 (`reserva atômica FOR UPDATE SKIP LOCKED`,
`reconciliation.ts`, `isSuppressed` pré-originação) — não há um teste
automatizado único que derrube o processo do worker de verdade
no meio de uma chamada para provar a recuperação; a lógica de
reconciliação em si tem teste unitário, mas o cenário "kill -9 do
processo" não foi exercitado nesta sessão. Lacuna conhecida.
- [x] Quality gate — **executado nesta fase**: `pnpm -r build` (typecheck
de todos os 7 workspaces, limpo), `eslint` em `api` e `frontend`
(limpo), `pnpm -r test` (46 testes, 100% passando), `docker compose
config` (válido), `docker compose ps` (8/8 serviços up, 6/8 com
healthcheck reportando "healthy" — `asterisk-events` e
`dialer-worker` não têm HTTP exposto para healthcheck formal, rodam
sem crash e com log de atividade normal), `scripts/healthcheck.sh`
(tudo OK).
- [x] Aceite de segurança (seção 92) — **todos os 13 itens verificados ao
vivo nesta fase** contra o sistema real (não apenas por inspeção de
código): nenhuma senha/.env no git, Postgres só em 127.0.0.1, Redis
sem porta publicada, AMI/ARI bloqueados da LAN por nftables, rate
limit de login testado (7ª tentativa consecutiva → 429), RBAC
testado (usuário `agent` → 403 em `/asterisk/status`, `/trunks`,
`/users`), auto-elevação negada, sem SQL injection (Prisma
parametrizado em toda parte), sem path traversal (nenhum caminho de
arquivo é construído a partir de input do usuário — nem no dialplan
gerado, nem no download de CSV, cujo `importId` é validado como UUID
antes de compor o header), logs sem credenciais (varredura real nos
logs do container, `redact.paths` do pino confirmado). Ver
`docs/SECURITY.md` para o detalhamento.
- [x] Aceite Asterisk (seção 93) — **os 5 comandos executados e
documentados nesta fase** em `docs/RELATORIO_FINAL.md`:
`core show version`, `core show uptime`, `pjsip show endpoints`,
`pjsip show contacts`, `queue show` (últimos três vazios porque os
fixtures de teste foram removidos — comportamento esperado).
Comunicação `API -> AMI -> Asterisk` validada via
`GET /api/asterisk/status` retornando `amiControlConnection: up`.
- [x] README final completo — `README.md` reescrito nesta fase com
arquitetura, requisitos, instalação, primeiro acesso e mapa de toda
a documentação em `docs/`.
- [x] Relatório final da implementação — `docs/RELATORIO_FINAL.md`
(formato da seção 96), incluindo credenciais de acesso web e do
banco de dados a pedido explícito do usuário.
---
**Nota de ambiente:** VM atual com 1.9 GiB RAM / 2 vCPU — adequada para dev e

View File

@@ -44,6 +44,7 @@
"fastify": "^5.2.1",
"ioredis": "^5.4.2",
"ms": "^2.1.3",
"prom-client": "^15.1.3",
"nestjs-pino": "^4.4.0",
"pino-http": "^10.5.0",
"reflect-metadata": "^0.2.2",

View File

@@ -5,6 +5,7 @@ import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { AgentConsoleService } from './agent-console.service';
import { AgentLoginDto } from './dto/agent-login.dto';
import { AgentPauseDto } from './dto/agent-pause.dto';
import { DisposeCallDto } from './dto/dispose-call.dto';
// Sem @RequirePermissions dedicada: qualquer usuário autenticado com um
// Agent associado pode operar sua própria tela de agente (agente.md seção
@@ -74,4 +75,16 @@ export class AgentConsoleController {
userAgent: request.headers['user-agent'],
});
}
@Post('dispose')
dispose(
@Body() dto: DisposeCallDto,
@CurrentUser() user: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.agentConsoleService.dispose(user.id, dto, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
}

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { SuppressionModule } from '../suppression/suppression.module';
import { AgentConsoleController } from './agent-console.controller';
import { AgentConsoleService } from './agent-console.service';
@Module({
imports: [SuppressionModule],
controllers: [AgentConsoleController],
providers: [AgentConsoleService],
})

View File

@@ -3,15 +3,18 @@ import {
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AgentState } from '@b2bcall/database';
import { AgentState, DispositionAction } from '@b2bcall/database';
import type { TelephonyProvider } from '@b2bcall/telephony';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import type { RequestContext } from '../auth/auth.service';
import { TELEPHONY_PROVIDER } from '../telephony/telephony.module';
import { SuppressionService } from '../suppression/suppression.service';
import { AgentLoginDto } from './dto/agent-login.dto';
import { AgentPauseDto } from './dto/agent-pause.dto';
import { DisposeCallDto } from './dto/dispose-call.dto';
function interfaceFor(extension: string): string {
return `PJSIP/${extension}`;
@@ -22,6 +25,7 @@ export class AgentConsoleService {
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly suppression: SuppressionService,
@Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider,
) {}
@@ -259,4 +263,115 @@ export class AgentConsoleService {
return { ok: true };
}
// Disposição da chamada (agente.md seção 40). Uma disposição pode
// disparar uma ação: CALLBACK cria um agendamento (seção 41), DO_NOT_CALL
// adiciona o lead à lista de supressão automaticamente. Também abre o
// estado WRAP_UP do agente (seção 39) — a transição automática de volta
// para AVAILABLE é feita pela varredura periódica do dialer-worker
// (wrapUpSeconds da fila), nunca por um timer em memória (não sobrevive a
// restart).
async dispose(userId: string, dto: DisposeCallDto, ctx: RequestContext) {
const agent = await this.getAgentForUserOrThrow(userId);
const attempt = await this.prisma.dialAttempt.findUnique({
where: { id: dto.dialAttemptId },
include: { lead: true, campaign: true },
});
if (!attempt)
throw new NotFoundException('Tentativa de chamada não encontrada.');
if (attempt.agentId !== agent.id) {
throw new ForbiddenException('Esta chamada não pertence a este agente.');
}
if (attempt.dispositionId) {
throw new BadRequestException('Esta chamada já possui uma disposição.');
}
const disposition = await this.prisma.callDisposition.findUnique({
where: { id: dto.dispositionId },
});
if (!disposition || !disposition.active)
throw new BadRequestException('Disposição inválida.');
if (disposition.action === DispositionAction.CALLBACK && !dto.callbackAt) {
throw new BadRequestException(
'Esta disposição exige data/hora de retorno (callbackAt).',
);
}
await this.prisma.$transaction(async (tx) => {
await tx.dialAttempt.update({
where: { id: attempt.id },
data: { dispositionId: disposition.id, dispositionNotes: dto.notes },
});
if (disposition.action === DispositionAction.CALLBACK) {
await tx.callback.create({
data: {
leadId: attempt.leadId,
campaignId: attempt.campaignId,
preferredAgentId: dto.preferSameAgent ? agent.id : undefined,
scheduledAt: new Date(dto.callbackAt!),
notes: dto.notes,
},
});
await tx.lead.update({
where: { id: attempt.leadId },
data: { status: 'CALLBACK' },
});
} else if (disposition.action === DispositionAction.DO_NOT_CALL) {
await tx.lead.update({
where: { id: attempt.leadId },
data: { status: 'DO_NOT_CALL' },
});
}
});
if (disposition.action === DispositionAction.DO_NOT_CALL) {
// Fora da transação: SuppressionService já audita e normaliza por si,
// reaproveitado em vez de duplicar a lógica de normalização de telefone.
await this.suppression.add(
{
phone: attempt.lead.phone,
reason: `Disposição: ${disposition.name}`,
},
{ id: userId },
ctx,
);
}
await this.transition(agent.id, AgentState.WRAP_UP);
// Pausa o agente nas filas durante o wrap-up — sem isso o Asterisk
// poderia rotear uma nova chamada para ele antes de terminar o
// pós-atendimento (agente.md seção 39). Revertido pela varredura
// periódica do dialer-worker quando o wrap-up expira.
if (agent.currentExtension) {
const iface = `PJSIP/${agent.currentExtension}`;
for (const membership of agent.queues) {
try {
await this.telephony.queuePause({
interface: iface,
queue: membership.queue.name,
paused: true,
reason: 'wrap-up',
});
} catch {
// Inofensivo se já estiver pausado/não for membro.
}
}
}
await this.audit.log({
userId,
action: 'call_disposed',
entityType: 'dial_attempt',
entityId: attempt.id,
after: { dispositionId: disposition.id, action: disposition.action },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return this.me(userId);
}
}

View File

@@ -0,0 +1,31 @@
import {
IsBoolean,
IsISO8601,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
export class DisposeCallDto {
@IsUUID('4')
dialAttemptId!: string;
@IsUUID('4')
dispositionId!: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
// Obrigatório apenas quando a disposição tiver action=CALLBACK (validado
// no service, que é quem conhece a disposição real).
@IsOptional()
@IsISO8601()
callbackAt?: string;
@IsOptional()
@IsBoolean()
preferSameAgent?: boolean;
}

View File

@@ -30,6 +30,8 @@ import { LeadsModule } from './leads/leads.module';
import { ReportsModule } from './reports/reports.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { ComplianceModule } from './compliance/compliance.module';
import { MetricsModule } from './metrics/metrics.module';
import { CallbacksModule } from './callbacks/callbacks.module';
import { AuthGuard } from './common/guards/auth.guard';
import { PermissionsGuard } from './common/guards/permissions.guard';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
@@ -73,6 +75,8 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
TrunksModule,
ExtensionsModule,
MonitoringModule,
MetricsModule,
CallbacksModule,
DialplanModule,
AsteriskAdminModule,
PauseReasonsModule,

View File

@@ -0,0 +1,33 @@
import { Controller, Get, Query } from '@nestjs/common';
import { IsOptional, IsUUID } from 'class-validator';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { PrismaService } from '../prisma/prisma.service';
class QueryCallbacksDto {
@IsOptional()
@IsUUID('4')
campaignId?: string;
}
// Consulta de callbacks agendados (agente.md seção 41/52). O agendamento em
// si acontece via POST /api/agent-console/dispose (disposição com
// action=CALLBACK); a movimentação automática do lead de volta para
// discagem no horário certo é feita pela varredura periódica do
// dialer-worker (callback-sweep.ts).
@Controller('callbacks')
export class CallbacksController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@RequirePermissions('campaigns.view')
list(@Query() query: QueryCallbacksDto) {
return this.prisma.callback.findMany({
where: { campaignId: query.campaignId, completed: false },
orderBy: { scheduledAt: 'asc' },
include: {
lead: { select: { name: true, phone: true } },
campaign: { select: { name: true } },
},
});
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { CallbacksController } from './callbacks.controller';
@Module({
controllers: [CallbacksController],
})
export class CallbacksModule {}

View File

@@ -0,0 +1,146 @@
import { Controller, Get, Header, Inject } from '@nestjs/common';
import { Registry, Gauge } from 'prom-client';
import type { TelephonyProvider } from '@b2bcall/telephony';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { TELEPHONY_PROVIDER } from '../telephony/telephony.module';
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
// Endpoint Prometheus (agente.md seção 89). Protegido como qualquer outra
// rota operacional (monitoring.view) — não é proxiado publicamente pelo
// Nginx (ver infrastructure/nginx/nginx.conf, só /api/ é exposto e exige
// sessão válida via o AuthGuard global). Todos os valores vêm de consultas
// reais ao Postgres/Redis/AMI no momento do scrape — nunca contadores
// acumulados em memória que poderiam dessincronizar após um restart
// (agente.md seção 72: nunca dado fake).
@Controller('metrics')
export class MetricsController {
private readonly registry = new Registry();
private readonly callsTotal = new Gauge({
name: 'b2bcall_calls_total',
help: 'Total de tentativas de discagem hoje',
registers: [this.registry],
});
private readonly callsAnsweredTotal = new Gauge({
name: 'b2bcall_calls_answered_total',
help: 'Total de chamadas completadas hoje',
registers: [this.registry],
});
private readonly callsAbandonedTotal = new Gauge({
name: 'b2bcall_calls_abandoned_total',
help: 'Total de chamadas abandonadas hoje (hangup_cause=ABANDONED)',
registers: [this.registry],
});
private readonly campaignCps = new Gauge({
name: 'b2bcall_campaign_cps',
help: 'CPS máximo configurado por campanha em execução',
labelNames: ['campaign'],
registers: [this.registry],
});
private readonly agentsAvailable = new Gauge({
name: 'b2bcall_agents_available',
help: 'Agentes disponíveis agora',
registers: [this.registry],
});
private readonly agentsBusy = new Gauge({
name: 'b2bcall_agents_busy',
help: 'Agentes em chamada agora',
registers: [this.registry],
});
private readonly agentsPaused = new Gauge({
name: 'b2bcall_agents_paused',
help: 'Agentes pausados agora',
registers: [this.registry],
});
private readonly queueWaiting = new Gauge({
name: 'b2bcall_queue_waiting',
help: 'Chamadas aguardando por fila',
labelNames: ['queue'],
registers: [this.registry],
});
private readonly dialerActiveCalls = new Gauge({
name: 'b2bcall_dialer_active_calls',
help: 'Chamadas ativas do discador (discando/tocando/na fila/com agente)',
registers: [this.registry],
});
constructor(
private readonly prisma: PrismaService,
@Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider,
) {}
@Get()
@RequirePermissions('monitoring.view')
@Header('Content-Type', 'text/plain; version=0.0.4')
async metrics(): Promise<string> {
const since = startOfToday();
const [
callsToday,
answeredToday,
abandonedToday,
activeCalls,
runningCampaigns,
openAgentStates,
] = await Promise.all([
this.prisma.dialAttempt.count({ where: { startedAt: { gte: since } } }),
this.prisma.dialAttempt.count({
where: { startedAt: { gte: since }, state: 'COMPLETED' },
}),
this.prisma.dialAttempt.count({
where: { startedAt: { gte: since }, hangupCause: 'ABANDONED' },
}),
this.prisma.dialAttempt.count({
where: {
state: {
in: ['ORIGINATING', 'RINGING', 'QUEUED', 'AGENT_CONNECTED'],
},
},
}),
this.prisma.campaign.findMany({
where: { status: 'RUNNING' },
select: { name: true, maxCps: true },
}),
this.prisma.agentStateEvent.findMany({
where: { endedAt: null },
select: { state: true },
}),
]);
this.callsTotal.set(callsToday);
this.callsAnsweredTotal.set(answeredToday);
this.callsAbandonedTotal.set(abandonedToday);
this.dialerActiveCalls.set(activeCalls);
this.campaignCps.reset();
for (const c of runningCampaigns)
this.campaignCps.set({ campaign: c.name }, c.maxCps);
this.agentsAvailable.set(
openAgentStates.filter((s) => s.state === 'AVAILABLE').length,
);
this.agentsBusy.set(
openAgentStates.filter(
(s) => s.state === 'IN_CALL' || s.state === 'RINGING',
).length,
);
this.agentsPaused.set(
openAgentStates.filter((s) => s.state === 'PAUSED').length,
);
this.queueWaiting.reset();
if (this.telephony.isConnected()) {
const liveQueues = await this.telephony.queueStatus();
for (const q of liveQueues)
this.queueWaiting.set({ queue: q.queue }, q.entries.length);
}
return this.registry.metrics();
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { MetricsController } from './metrics.controller';
@Module({
controllers: [MetricsController],
})
export class MetricsModule {}

View File

@@ -0,0 +1,29 @@
import { PrismaClient } from '@b2bcall/database';
import { logger } from './logger';
// Ativa callbacks agendados (agente.md seção 41) cujo horário chegou. O
// lead fica em status CALLBACK enquanto aguarda (não elegível para
// discagem — ver lead-repository.ts, que só reserva READY/BUSY/NO_ANSWER/
// FAILED); ao vencer, volta para READY com next_attempt_at = agora, mesmo
// caminho de qualquer retry normal. "completed" aqui significa "já foi
// reenfileirado para discagem", não "a ligação de retorno aconteceu" — isso
// é registrado depois via nova disposição, como qualquer outra tentativa.
export async function activateDueCallbacks(prisma: PrismaClient): Promise<number> {
const due = await prisma.callback.findMany({
where: { completed: false, scheduledAt: { lte: new Date() } },
});
if (due.length === 0) return 0;
for (const callback of due) {
await prisma.$transaction([
prisma.lead.updateMany({
where: { id: callback.leadId, status: 'CALLBACK' },
data: { status: 'READY', nextAttemptAt: new Date() },
}),
prisma.callback.update({ where: { id: callback.id }, data: { completed: true } }),
]);
}
logger.info({ count: due.length }, 'Callbacks vencidos reativados para discagem');
return due.length;
}

View File

@@ -3,10 +3,13 @@ import Redis from 'ioredis';
import { AsteriskTelephonyProvider } from '@b2bcall/telephony';
import { CampaignWorker } from './campaign-worker';
import { reconcileOrphanedAttempts } from './reconciliation';
import { activateDueCallbacks } from './callback-sweep';
import { completeExpiredWrapUps } from './wrap-up-sweep';
import { logger } from './logger';
const TICK_INTERVAL_MS = 2000;
const RECONCILE_INTERVAL_MS = 60_000;
const SWEEP_INTERVAL_MS = 15_000;
const DIALER_SIMULATION = process.env.DIALER_SIMULATION === 'true';
async function main() {
@@ -23,13 +26,17 @@ async function main() {
if (DIALER_SIMULATION) {
logger.warn('DIALER_SIMULATION=true — nenhuma chamada real será originada.');
} else {
try {
await telephony.connect();
logger.info('Conectado ao AMI do Asterisk.');
} catch (err) {
logger.error({ err }, 'Falha ao conectar ao AMI — tentará reconectar automaticamente.');
}
}
// Conecta ao AMI mesmo em modo simulação: DIALER_SIMULATION só impede
// originação real de chamadas (campaign-worker.ts), não ações
// administrativas de fila (pause/unpause de wrap-up, QueueAdd/Remove)
// que precisam refletir no Asterisk de verdade para o teste do console
// do agente fazer sentido.
try {
await telephony.connect();
logger.info('Conectado ao AMI do Asterisk.');
} catch (err) {
logger.error({ err }, 'Falha ao conectar ao AMI — tentará reconectar automaticamente.');
}
const worker = new CampaignWorker(prisma, redis, telephony);
@@ -65,6 +72,10 @@ async function main() {
() => void reconcileOrphanedAttempts(prisma).catch((err) => logger.error({ err }, 'Erro na reconciliação')),
RECONCILE_INTERVAL_MS,
);
const sweepInterval = setInterval(() => {
void activateDueCallbacks(prisma).catch((err) => logger.error({ err }, 'Erro ativando callbacks'));
void completeExpiredWrapUps(prisma, telephony).catch((err) => logger.error({ err }, 'Erro concluindo wrap-ups'));
}, SWEEP_INTERVAL_MS);
const shutdown = async () => {
if (!running) return;
@@ -72,6 +83,7 @@ async function main() {
logger.info('Encerrando dialer-worker...');
clearInterval(interval);
clearInterval(reconcileInterval);
clearInterval(sweepInterval);
telephony.disconnect();
await redis.quit();
await prisma.$disconnect();

View File

@@ -0,0 +1,55 @@
import { PrismaClient } from '@b2bcall/database';
import type { TelephonyProvider } from '@b2bcall/telephony';
import { logger } from './logger';
const DEFAULT_WRAP_UP_SECONDS = 30;
// Transição automática WRAP_UP -> AVAILABLE (agente.md seção 39). Feita por
// varredura periódica (não por timer em memória) para sobreviver a restart
// do worker — mesmo padrão de reconciliation.ts. O tempo de wrap-up usado é
// o maior configurado entre as filas do agente (default conservador se ele
// não pertencer a nenhuma fila). Também desfaz a pausa de fila aplicada no
// início do wrap-up (ver AgentConsoleService.dispose).
export async function completeExpiredWrapUps(prisma: PrismaClient, telephony: TelephonyProvider): Promise<number> {
const openWrapUps = await prisma.agentStateEvent.findMany({
where: { state: 'WRAP_UP', endedAt: null },
include: { agent: { include: { queues: { include: { queue: true } } } } },
});
if (openWrapUps.length === 0) return 0;
const now = Date.now();
let completed = 0;
for (const event of openWrapUps) {
const wrapUpSeconds =
event.agent.queues.length > 0
? Math.max(...event.agent.queues.map((m) => m.queue.wrapUpTime))
: DEFAULT_WRAP_UP_SECONDS;
const elapsedMs = now - event.startedAt.getTime();
if (elapsedMs < wrapUpSeconds * 1000) continue;
await prisma.$transaction([
prisma.agentStateEvent.update({ where: { id: event.id }, data: { endedAt: new Date() } }),
prisma.agentStateEvent.create({ data: { agentId: event.agentId, state: 'AVAILABLE' } }),
]);
if (event.agent.currentExtension && telephony.isConnected()) {
const iface = `PJSIP/${event.agent.currentExtension}`;
for (const membership of event.agent.queues) {
try {
await telephony.queuePause({ interface: iface, queue: membership.queue.name, paused: false });
} catch {
// Inofensivo se já não estiver pausado.
}
}
}
completed += 1;
}
if (completed > 0) {
logger.info({ count: completed }, 'Wrap-up concluído automaticamente para agentes elegíveis');
}
return completed;
}

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.

36
pnpm-lock.yaml generated
View File

@@ -88,6 +88,9 @@ importers:
pino-http:
specifier: ^10.5.0
version: 10.5.0
prom-client:
specifier: ^15.1.3
version: 15.1.3
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -279,7 +282,7 @@ importers:
version: 0.545.0(react@19.2.8)
next:
specifier: ^15.5.4
version: 15.5.24(@babel/core@7.29.7(supports-color@8.1.1))(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
version: 15.5.24(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react:
specifier: ^19.2.0
version: 19.2.8
@@ -1416,6 +1419,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@opentelemetry/api@1.9.1':
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
engines: {node: '>=8.0.0'}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
@@ -2597,6 +2604,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bintrees@1.0.2:
resolution: {integrity: sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
@@ -4566,6 +4576,11 @@ packages:
process-warning@5.1.0:
resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==}
prom-client@15.1.3:
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
engines: {node: ^16 || ^18 || >=20}
deprecated: prom-client has been replaced by @prometheus-io/client
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -5030,6 +5045,9 @@ packages:
resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
engines: {node: '>=6'}
tdigest@0.1.3:
resolution: {integrity: sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==}
terser-webpack-plugin@5.6.1:
resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==}
engines: {node: '>= 10.13.0'}
@@ -6544,6 +6562,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@opentelemetry/api@1.9.1': {}
'@paralleldrive/cuid2@2.3.1':
dependencies:
'@noble/hashes': 1.8.0
@@ -7779,6 +7799,8 @@ snapshots:
baseline-browser-mapping@2.11.19: {}
bintrees@1.0.2: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
@@ -9747,7 +9769,7 @@ snapshots:
pino-http: 10.5.0
rxjs: 7.8.2
next@15.5.24(@babel/core@7.29.7(supports-color@8.1.1))(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
next@15.5.24(@babel/core@7.29.7(supports-color@8.1.1))(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@next/env': 15.5.24
'@swc/helpers': 0.5.15
@@ -9765,6 +9787,7 @@ snapshots:
'@next/swc-linux-x64-musl': 15.5.24
'@next/swc-win32-arm64-msvc': 15.5.24
'@next/swc-win32-x64-msvc': 15.5.24
'@opentelemetry/api': 1.9.1
sharp: 0.35.4(@types/node@24.13.3)
transitivePeerDependencies:
- '@babel/core'
@@ -10054,6 +10077,11 @@ snapshots:
process-warning@5.1.0: {}
prom-client@15.1.3:
dependencies:
'@opentelemetry/api': 1.9.1
tdigest: 0.1.3
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -10578,6 +10606,10 @@ snapshots:
tapable@2.3.3: {}
tdigest@0.1.3:
dependencies:
bintrees: 1.0.2
terser-webpack-plugin@5.6.1(lightningcss@1.32.0)(postcss@8.5.26)(uglify-js@3.19.3)(webpack@5.106.2(lightningcss@1.32.0)(postcss@8.5.26)(uglify-js@3.19.3)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31

44
scripts/backup.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Backup do B2BCall: dump completo do Postgres (schemas "public" + "asterisk"
# — cobre domínio da aplicação E objetos PJSIP realtime, já que ambos vivem
# no mesmo banco) e cópia do .env. Os arquivos gerados por Telefonia ->
# Dialplan / Call Center -> Filas NÃO precisam de backup separado: são
# derivados de dialplan_versions/queues (Postgres) e recriados na próxima
# publicação (agente.md seção 97: "Asterisk não é banco de negócio").
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .env ]; then
echo "Erro: .env não encontrado em $(pwd)." >&2
exit 1
fi
set -a; source .env; set +a
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_DIR="${BACKUP_DIR:-backups}"
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
mkdir -p "$BACKUP_DIR"
echo "==> Backup do Postgres (schemas public + asterisk)..."
DUMP_FILE="$BACKUP_DIR/postgres-${TIMESTAMP}.dump"
docker compose exec -T postgres pg_dump -U "$POSTGRES_USER" -Fc "$POSTGRES_DB" > "$DUMP_FILE"
chmod 600 "$DUMP_FILE"
echo " -> $DUMP_FILE ($(du -h "$DUMP_FILE" | cut -f1))"
echo "==> Backup do .env (contém segredos — mantido fora do git, chmod 600)..."
ENV_BACKUP="$BACKUP_DIR/env-${TIMESTAMP}.bak"
cp .env "$ENV_BACKUP"
chmod 600 "$ENV_BACKUP"
echo " -> $ENV_BACKUP"
if [ -f FIRST_LOGIN.txt ]; then
echo "==> FIRST_LOGIN.txt ainda existe no servidor — copiando para o backup e recomendando remoção do original após o primeiro acesso."
cp FIRST_LOGIN.txt "$BACKUP_DIR/FIRST_LOGIN-${TIMESTAMP}.txt"
chmod 600 "$BACKUP_DIR/FIRST_LOGIN-${TIMESTAMP}.txt"
fi
echo "==> Removendo backups com mais de ${RETENTION_DAYS} dias..."
find "$BACKUP_DIR" -maxdepth 1 -type f -mtime "+${RETENTION_DAYS}" -print -delete || true
echo "==> Backup concluído."
ls -lh "$BACKUP_DIR" | tail -n +1

48
scripts/healthcheck.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Checagem rápida de saúde de toda a stack (agente.md seção 62).
set -uo pipefail
cd "$(dirname "$0")/.."
FAIL=0
echo "=== docker compose ps ==="
docker compose ps
echo ""
echo "=== /api/health (via Nginx) ==="
if curl -sf http://127.0.0.1/api/health; then
echo ""
else
echo "FALHOU"
FAIL=1
fi
echo ""
echo "=== Asterisk (core show uptime) ==="
if docker exec b2bcall-asterisk asterisk -rx "core show uptime" 2>/dev/null; then
:
else
echo "FALHOU"
FAIL=1
fi
echo ""
echo "=== Asterisk (pjsip show endpoints) ==="
docker exec b2bcall-asterisk asterisk -rx "pjsip show endpoints" 2>/dev/null || { echo "FALHOU"; FAIL=1; }
echo ""
echo "=== nftables (regras do B2BCall ativas?) ==="
if nft list table inet b2bcall_fw >/dev/null 2>&1; then
echo "OK"
else
echo "FALHOU — regras não carregadas"
FAIL=1
fi
echo ""
if [ "$FAIL" -eq 0 ]; then
echo "==> Tudo OK."
else
echo "==> Um ou mais checks falharam. Veja acima."
fi
exit "$FAIL"

133
scripts/install.sh Executable file
View File

@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# Instalação do B2BCall em um Debian 13 (trixie) limpo (agente.md seção 86).
# Idempotente na medida do possível: pula passos já satisfeitos.
set -euo pipefail
cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
echo "############################################################"
echo "# B2BCall - instalação"
echo "############################################################"
# 1. Permissões -------------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
echo "Erro: rode este script como root (sudo)." >&2
exit 1
fi
# 2. Dependências de sistema -------------------------------------------------
echo "==> Instalando dependências de sistema..."
apt-get update -qq
apt-get install -y -qq git curl ca-certificates gnupg lsb-release apt-transport-https openssl
if ! command -v node >/dev/null 2>&1; then
echo "==> Instalando Node.js 24 LTS..."
curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh
bash /tmp/nodesource_setup.sh
apt-get install -y -qq nodejs
fi
echo "==> Habilitando pnpm via corepack..."
corepack enable
corepack prepare pnpm@11.24.0 --activate
# 3. Docker -------------------------------------------------------------
if ! command -v docker >/dev/null 2>&1; then
echo "==> Instalando Docker Engine + Compose plugin..."
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
CODENAME=$(. /etc/os-release && echo "$VERSION_CODENAME")
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $CODENAME stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
fi
# 4. Diretórios -------------------------------------------------------------
mkdir -p backups
# 5/6. Secrets e .env ---------------------------------------------------
if [ ! -f .env ]; then
echo "==> Gerando .env a partir de .env.example..."
cp .env.example .env
chmod 600 .env
eval "$(bash scripts/generate-secrets.sh | sed 's/^/GEN_/')"
sed -i \
-e "s#^POSTGRES_PASSWORD=.*#POSTGRES_PASSWORD=${GEN_POSTGRES_PASSWORD}#" \
-e "s#^DATABASE_URL=.*#DATABASE_URL=postgresql://b2bcall:${GEN_POSTGRES_PASSWORD}@postgres:5432/b2bcall?schema=public#" \
-e "s#^REDIS_PASSWORD=.*#REDIS_PASSWORD=${GEN_REDIS_PASSWORD}#" \
-e "s#^REDIS_URL=.*#REDIS_URL=redis://:${GEN_REDIS_PASSWORD}@redis:6379#" \
-e "s#^JWT_ACCESS_SECRET=.*#JWT_ACCESS_SECRET=${GEN_JWT_ACCESS_SECRET}#" \
-e "s#^JWT_REFRESH_SECRET=.*#JWT_REFRESH_SECRET=${GEN_JWT_REFRESH_SECRET}#" \
-e "s#^SECRETS_MASTER_KEY=.*#SECRETS_MASTER_KEY=${GEN_SECRETS_MASTER_KEY}#" \
-e "s#^AMI_SECRET=.*#AMI_SECRET=${GEN_AMI_SECRET}#" \
-e "s#^ARI_SECRET=.*#ARI_SECRET=${GEN_ARI_SECRET}#" \
.env
echo " .env gerado com segredos aleatórios fortes."
else
echo "==> .env já existe, mantendo."
fi
set -a; source .env; set +a
# nftables (proteção AMI/ARI/SIP contra a LAN, nunca bloqueia SSH) -------
if command -v nft >/dev/null 2>&1; then
echo "==> Instalando regras de firewall (nftables)..."
ln -sf "$REPO_ROOT/infrastructure/nftables/b2bcall-nftables.service" /etc/systemd/system/b2bcall-nftables.service
systemctl daemon-reload
systemctl enable b2bcall-nftables.service
systemctl restart b2bcall-nftables.service
fi
if ! grep -q "^vm.overcommit_memory" /etc/sysctl.d/99-b2bcall-redis.conf 2>/dev/null; then
echo "vm.overcommit_memory = 1" > /etc/sysctl.d/99-b2bcall-redis.conf
sysctl -w vm.overcommit_memory=1 >/dev/null
fi
# 7. Dependências do monorepo + build ----------------------------------
echo "==> Instalando dependências do monorepo (pnpm install)..."
pnpm install --frozen-lockfile
echo "==> Buildando imagens Docker (pode levar vários minutos na primeira vez)..."
docker compose build
# 8. Postgres/Redis -----------------------------------------------------
echo "==> Subindo Postgres e Redis..."
docker compose up -d postgres redis
echo " Aguardando ficarem saudáveis..."
until [ "$(docker inspect -f '{{.State.Health.Status}}' b2bcall-postgres 2>/dev/null)" = "healthy" ] && \
[ "$(docker inspect -f '{{.State.Health.Status}}' b2bcall-redis 2>/dev/null)" = "healthy" ]; do
sleep 2
done
# 9. Migrations + seed ----------------------------------------------------
echo "==> Aplicando migrations e seed inicial (permissões, perfis, super_admin)..."
(
cd packages/database
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT:-5432}/${POSTGRES_DB}?schema=public"
pnpm exec prisma migrate deploy
pnpm run seed
)
# 10/11. Asterisk + demais serviços --------------------------------------
echo "==> Subindo Asterisk e demais serviços..."
docker compose up -d
# 12. Health checks -------------------------------------------------------
echo "==> Aguardando serviços ficarem saudáveis..."
sleep 15
bash scripts/healthcheck.sh || true
# 13. Resultado -----------------------------------------------------------
echo ""
echo "############################################################"
echo "# Instalação concluída"
echo "############################################################"
echo "URL: http://$(hostname -I | awk '{print $1}')/"
echo "API: http://$(hostname -I | awk '{print $1}')/api/health"
if [ -f FIRST_LOGIN.txt ]; then
echo ""
echo "Credenciais do primeiro acesso em: $REPO_ROOT/FIRST_LOGIN.txt"
echo "(remova este arquivo do servidor após o primeiro login)"
fi

40
scripts/restore.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Restaura um dump gerado por scripts/backup.sh.
# Uso: scripts/restore.sh backups/postgres-20260101-120000.dump
#
# ATENÇÃO: operação destrutiva — substitui todo o conteúdo atual do banco
# (schemas public + asterisk) pelo conteúdo do dump.
set -euo pipefail
cd "$(dirname "$0")/.."
DUMP_FILE="${1:?Uso: scripts/restore.sh <arquivo .dump>}"
if [ ! -f "$DUMP_FILE" ]; then
echo "Erro: arquivo não encontrado: $DUMP_FILE" >&2
exit 1
fi
set -a; source .env; set +a
echo "########################################################################"
echo "# ATENÇÃO: isso vai APAGAR e SUBSTITUIR o banco '$POSTGRES_DB' atual #"
echo "# pelo conteúdo de: $DUMP_FILE"
echo "# Isso inclui campanhas, leads, chamadas, usuários E os objetos PJSIP #"
echo "# realtime (troncos/ramais deixarão de aparecer no Asterisk se não #"
echo "# estiverem no dump)."
echo "########################################################################"
read -r -p "Digite 'restaurar' para confirmar: " CONFIRM
if [ "$CONFIRM" != "restaurar" ]; then
echo "Cancelado."
exit 1
fi
echo "==> Parando serviços de aplicação (mantendo Postgres no ar)..."
docker compose stop api asterisk-events dialer-worker frontend nginx 2>/dev/null || true
echo "==> Restaurando dump..."
docker compose exec -T postgres pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists < "$DUMP_FILE"
echo "==> Reiniciando serviços de aplicação..."
docker compose up -d api asterisk-events dialer-worker frontend nginx
echo "==> Restore concluído. Verifique com: scripts/healthcheck.sh"

42
scripts/update.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Atualiza o B2BCall: puxa código novo, aplica migrations pendentes, rebuilda
# as imagens que mudaram e recria os containers necessários.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ ! -f .env ]; then
echo "Erro: .env não encontrado em $(pwd)." >&2
exit 1
fi
set -a; source .env; set +a
if [ -d .git ]; then
echo "==> git status antes de atualizar:"
git status --short
if [ -n "$(git status --porcelain)" ]; then
echo "AVISO: há alterações locais não commitadas. Prosseguindo mesmo assim (elas não são de código servido pelos containers, que usam o que já está em disco)."
fi
echo "==> git pull..."
git pull --ff-only
fi
echo "==> Instalando dependências (pnpm install)..."
pnpm install --frozen-lockfile
echo "==> Aplicando migrations pendentes do Prisma..."
(
cd packages/database
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT:-5432}/${POSTGRES_DB}?schema=public"
pnpm exec prisma migrate deploy
)
echo "==> Rebuildando imagens Docker..."
docker compose build
echo "==> Recriando containers com as novas imagens..."
docker compose up -d
echo "==> Aguardando serviços ficarem saudáveis..."
sleep 10
bash scripts/healthcheck.sh