From 80e72881b29e1cea4f69617f285495a5815d0b2e Mon Sep 17 00:00:00 2001 From: B2BCall Bootstrap Date: Thu, 27 Aug 2026 19:16:14 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Fase=209/10=20=E2=80=94=20m=C3=A9tricas?= =?UTF-8?q?,=20scripts=20operacionais,=20callback/wrap-up=20e=20aceite=20f?= =?UTF-8?q?inal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .gitignore | 7 +- CHANGELOG.md | 72 +++++++ README.md | 83 +++++++++ TODO.md | 175 +++++++++++++++--- apps/api/package.json | 1 + .../agent-console/agent-console.controller.ts | 13 ++ .../src/agent-console/agent-console.module.ts | 2 + .../agent-console/agent-console.service.ts | 117 +++++++++++- .../src/agent-console/dto/dispose-call.dto.ts | 31 ++++ apps/api/src/app.module.ts | 4 + .../api/src/callbacks/callbacks.controller.ts | 33 ++++ apps/api/src/callbacks/callbacks.module.ts | 7 + apps/api/src/metrics/metrics.controller.ts | 146 +++++++++++++++ apps/api/src/metrics/metrics.module.ts | 7 + apps/dialer-worker/src/callback-sweep.ts | 29 +++ apps/dialer-worker/src/main.ts | 26 ++- apps/dialer-worker/src/wrap-up-sweep.ts | 55 ++++++ docs/API.md | 72 +++++++ docs/ASTERISK.md | 94 ++++++++++ docs/BACKUP_RESTORE.md | 79 ++++++++ docs/DATABASE.md | 69 +++++++ docs/INSTALL.md | 92 +++++++++ docs/OPENSIPS.md | 51 +++++ docs/OPERATIONS.md | 78 ++++++++ docs/RELATORIO_FINAL.md | 157 ++++++++++++++++ docs/SECURITY.md | 112 +++++++++++ docs/TROUBLESHOOTING.md | 108 +++++++++++ pnpm-lock.yaml | 36 +++- scripts/backup.sh | 44 +++++ scripts/healthcheck.sh | 48 +++++ scripts/install.sh | 133 +++++++++++++ scripts/restore.sh | 40 ++++ scripts/update.sh | 42 +++++ 33 files changed, 2024 insertions(+), 39 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 apps/api/src/agent-console/dto/dispose-call.dto.ts create mode 100644 apps/api/src/callbacks/callbacks.controller.ts create mode 100644 apps/api/src/callbacks/callbacks.module.ts create mode 100644 apps/api/src/metrics/metrics.controller.ts create mode 100644 apps/api/src/metrics/metrics.module.ts create mode 100644 apps/dialer-worker/src/callback-sweep.ts create mode 100644 apps/dialer-worker/src/wrap-up-sweep.ts create mode 100644 docs/API.md create mode 100644 docs/ASTERISK.md create mode 100644 docs/BACKUP_RESTORE.md create mode 100644 docs/DATABASE.md create mode 100644 docs/INSTALL.md create mode 100644 docs/OPENSIPS.md create mode 100644 docs/OPERATIONS.md create mode 100644 docs/RELATORIO_FINAL.md create mode 100644 docs/SECURITY.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100755 scripts/backup.sh create mode 100755 scripts/healthcheck.sh create mode 100755 scripts/install.sh create mode 100755 scripts/restore.sh create mode 100755 scripts/update.sh diff --git a/.gitignore b/.gitignore index e67aa61..008080c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ddf9df7 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4c8ead6 --- /dev/null +++ b/README.md @@ -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 /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. diff --git a/TODO.md b/TODO.md index 81be727..cae6a4b 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json index 36780e3..91c0218 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/agent-console/agent-console.controller.ts b/apps/api/src/agent-console/agent-console.controller.ts index aa6436a..3d5c50c 100644 --- a/apps/api/src/agent-console/agent-console.controller.ts +++ b/apps/api/src/agent-console/agent-console.controller.ts @@ -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'], + }); + } } diff --git a/apps/api/src/agent-console/agent-console.module.ts b/apps/api/src/agent-console/agent-console.module.ts index 88d39fb..63228da 100644 --- a/apps/api/src/agent-console/agent-console.module.ts +++ b/apps/api/src/agent-console/agent-console.module.ts @@ -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], }) diff --git a/apps/api/src/agent-console/agent-console.service.ts b/apps/api/src/agent-console/agent-console.service.ts index c00b9a3..5b06749 100644 --- a/apps/api/src/agent-console/agent-console.service.ts +++ b/apps/api/src/agent-console/agent-console.service.ts @@ -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); + } } diff --git a/apps/api/src/agent-console/dto/dispose-call.dto.ts b/apps/api/src/agent-console/dto/dispose-call.dto.ts new file mode 100644 index 0000000..7af28d3 --- /dev/null +++ b/apps/api/src/agent-console/dto/dispose-call.dto.ts @@ -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; +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 26b7020..a64c1f9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/callbacks/callbacks.controller.ts b/apps/api/src/callbacks/callbacks.controller.ts new file mode 100644 index 0000000..d19b71d --- /dev/null +++ b/apps/api/src/callbacks/callbacks.controller.ts @@ -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 } }, + }, + }); + } +} diff --git a/apps/api/src/callbacks/callbacks.module.ts b/apps/api/src/callbacks/callbacks.module.ts new file mode 100644 index 0000000..4bd359c --- /dev/null +++ b/apps/api/src/callbacks/callbacks.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { CallbacksController } from './callbacks.controller'; + +@Module({ + controllers: [CallbacksController], +}) +export class CallbacksModule {} diff --git a/apps/api/src/metrics/metrics.controller.ts b/apps/api/src/metrics/metrics.controller.ts new file mode 100644 index 0000000..058c54c --- /dev/null +++ b/apps/api/src/metrics/metrics.controller.ts @@ -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 { + 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(); + } +} diff --git a/apps/api/src/metrics/metrics.module.ts b/apps/api/src/metrics/metrics.module.ts new file mode 100644 index 0000000..16704cf --- /dev/null +++ b/apps/api/src/metrics/metrics.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { MetricsController } from './metrics.controller'; + +@Module({ + controllers: [MetricsController], +}) +export class MetricsModule {} diff --git a/apps/dialer-worker/src/callback-sweep.ts b/apps/dialer-worker/src/callback-sweep.ts new file mode 100644 index 0000000..4c2ba78 --- /dev/null +++ b/apps/dialer-worker/src/callback-sweep.ts @@ -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 { + 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; +} diff --git a/apps/dialer-worker/src/main.ts b/apps/dialer-worker/src/main.ts index 9d9dacc..5c08738 100644 --- a/apps/dialer-worker/src/main.ts +++ b/apps/dialer-worker/src/main.ts @@ -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(); diff --git a/apps/dialer-worker/src/wrap-up-sweep.ts b/apps/dialer-worker/src/wrap-up-sweep.ts new file mode 100644 index 0000000..f99426e --- /dev/null +++ b/apps/dialer-worker/src/wrap-up-sweep.ts @@ -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 { + 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; +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..4ba0178 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,72 @@ +# B2BCall — Referência da API + +Base URL (via Nginx): `http:///api` +Documentação interativa (Swagger, quando `SWAGGER_ENABLED=true`): `http:///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:///api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@b2bcall.local","password":"..."}' + +# Listar troncos (autenticado) +curl -b cookies.txt http:///api/trunks + +# Métricas Prometheus +curl -b cookies.txt http:///api/metrics +``` diff --git a/docs/ASTERISK.md b/docs/ASTERISK.md new file mode 100644 index 0000000..2518e59 --- /dev/null +++ b/docs/ASTERISK.md @@ -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`. diff --git a/docs/BACKUP_RESTORE.md b/docs/BACKUP_RESTORE.md new file mode 100644 index 0000000..727f5a3 --- /dev/null +++ b/docs/BACKUP_RESTORE.md @@ -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-.dump` — dump `pg_dump -Fc` (formato custom, comprime + e permite restore seletivo). +- `env-.bak` — cópia do `.env`. +- `FIRST_LOGIN-.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-.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. diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..6f141f3 --- /dev/null +++ b/docs/DATABASE.md @@ -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 # 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. diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..b9cd04d --- /dev/null +++ b/docs/INSTALL.md @@ -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 /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:///` +- API: `http:///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. diff --git a/docs/OPENSIPS.md b/docs/OPENSIPS.md new file mode 100644 index 0000000..aa685a6 --- /dev/null +++ b/docs/OPENSIPS.md @@ -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. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..e8e818d --- /dev/null +++ b/docs/OPERATIONS.md @@ -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-.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). diff --git a/docs/RELATORIO_FINAL.md b/docs/RELATORIO_FINAL.md new file mode 100644 index 0000000..f8f7796 --- /dev/null +++ b/docs/RELATORIO_FINAL.md @@ -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. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..ff97594 --- /dev/null +++ b/docs/SECURITY.md @@ -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 ""` 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. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..eac6ef4 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -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 ` — 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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5998add..39c2092 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..3af1fca --- /dev/null +++ b/scripts/backup.sh @@ -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 diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh new file mode 100755 index 0000000..2a3136a --- /dev/null +++ b/scripts/healthcheck.sh @@ -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" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..1008b6c --- /dev/null +++ b/scripts/install.sh @@ -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 diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..62605ae --- /dev/null +++ b/scripts/restore.sh @@ -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 }" +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" diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..3cc7760 --- /dev/null +++ b/scripts/update.sh @@ -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