# TODO — B2BCall ## PHASE 01 — Infrastructure - [x] Diagnóstico do servidor (Debian 13, 2 vCPU, ~1.9GB RAM, 26GB disco livre) - [x] Docker + Docker Compose instalados - [x] Estrutura de monorepo criada (apps/, packages/, infrastructure/, scripts/, docs/) - [x] PostgreSQL 18 (docker-compose, porta 127.0.0.1:5432) - [x] Redis 7 (docker-compose, porta 127.0.0.1:6379) - [x] Secrets gerados em `.env` (POSTGRES_PASSWORD, REDIS_PASSWORD, JWT_SECRET, JWT_REFRESH_SECRET, ENCRYPTION_KEY, ESL_PASSWORD) - [x] `FREESWITCH_PAT` configurado em `.env` (não commitado) - [x] FreeSWITCH (imagem própria via pacotes SignalWire, não compilada da fonte — ver docs/FREESWITCH.md; rodando, saudável, ~44MB RAM, senha ESL customizada, nenhuma porta exposta ao host) - [ ] nginx (reverse proxy) ## PHASE 05 — FreeSWITCH (agente.md secao 232) - [x] Imagem própria (`infrastructure/freeswitch/`), pacotes SignalWire (PAT via BuildKit secret, nunca na imagem final — verificado com `docker history`) - [x] Módulos mínimos carregados: sofia, event_socket, commands, dptools, callcenter, avmd, curl, local_stream, etc. (mod_xml_curl instalado mas desativado até existir b2bcall-fs-config) - [x] Senha do Event Socket trocada da padrão via entrypoint runtime (nunca fica na imagem); porta 8021 não publicada no host - [x] docs/FREESWITCH.md, docs/NETWORK_ARCHITECTURE.md (decisão de network_mode adiada pra quando existir tronco SIP real) - [ ] Diretório/dialplan ainda são os estáticos da config vanilla (ramais de teste 1000-1019, senhas fracas) — substituir por mod_xml_curl na fase Extensions/Trunks/Dialplan ## PHASE 06 — Event Socket (agente.md secao 21-25, 195) - [x] `packages/telephony`: interface `TelephonyProvider` + `FreeSwitchTelephonyProvider` (sobre a lib `esl`, reconexão com backoff já embutida na lib) - [x] `normalizeEslEvent()`: eventos ESL crus → vocabulário interno (secao 24) - [x] `apps/freeswitch-events` (b2bcall-fs-events): conexão ESL permanente, resubscreve a cada reconexão, publica eventos normalizados no canal Redis `b2bcall:events` - [x] Achado: FreeSWITCH 1.11 aplica ACL implícita (só loopback) sem `apply-inbound-acl` — bloqueava conexão de outro container mesmo com senha certa. Corrigido com ACL própria cobrindo loopback + rede Docker. - [x] Testado ponta a ponta com chamada loopback local: CALL_CREATED → CALL_ANSWERED → CALL_ENDED corretos no Redis - [ ] Reconciliação pós-reconexão (calls/agents/queues/registrations/gateways) — não é possível ainda, sem essas tabelas persistidas ## PHASE 07 — XML Curl (agente.md secao 26) - [x] `apps/freeswitch-config` (b2bcall-fs-config): responde ao protocolo XML Curl do FreeSWITCH (POST form-encoded → XML), containerizado - [x] `mod_xml_curl` reativado, binding restrito a `directory|dialplan` (não `configuration` — evita chamadas HTTP desnecessárias no boot) - [x] Por enquanto sempre "not found" (sem tabela extensions/dialplan ainda); verificado que a config estática vanilla continua funcionando como fallback (`user/8888` → SUBSCRIBER_ABSENT via fs-config, `user/1000` → USER_NOT_REGISTERED via config estática — achou o usuário) - [ ] Sem autenticação HTTP ainda — ok enquanto só responde "not found"; adicionar `gateway-credentials` antes de servir directory/dialplan reais ## PHASE 02 — SaaS Core - [x] Monorepo Node.js/TypeScript (pnpm workspaces, tsconfig base) - [x] Node 22 LTS + pnpm instalados no host - [x] `packages/database` (Prisma 7 + driver adapter `pg`, migration inicial) - [x] `packages/types` (TenantStatus, AgentState), `packages/shared` - [x] Tabela `tenants` criada via migration (seção 29 do agente.md) ## PHASE 03 — Tenant Isolation - [x] Tabelas `users` + `tenant_memberships` (tenant-scoped) - [x] RLS (`ENABLE`/`FORCE ROW LEVEL SECURITY` + policy) em `tenant_memberships` - [x] Tenant context via `set_config('app.current_tenant_id', ..., true)` (transaction-local) - [x] Helper `withTenantContext()` em `packages/database` - [x] Role de banco separado para runtime (`b2bcall_app`, sem SUPERUSER/BYPASSRLS) — achado crítico: o role padrão do Docker Postgres é SUPERUSER e SEMPRE ignora RLS, até com FORCE. Ver `docs/TENANT_ISOLATION.md`. - [x] Teste automatizado de isolamento (`pnpm --filter @b2bcall/database run test:isolation`) ## PHASE 04 — Authentication / RBAC - [x] `packages/auth`: hash Argon2id (`@node-rs/argon2`), JWT access token (`jose`), refresh token opaco com rotation - [x] Tabelas `roles`, `permissions`, `role_permissions`, `user_roles`, `sessions`, `audit_logs` - [x] `login()` / `refreshSession()` / `logout()` / `listUserTenants()` / `setActiveTenant()` - [x] `userHasPermission()` (RBAC com scope PLATFORM/TENANT) - [x] Seed: catálogo de permissions + roles de sistema + Platform Super Admin inicial (senha em `FIRST_LOGIN.txt`, fora do Git, `mustChangePassword=true`) - [x] Teste automatizado (`pnpm --filter @b2bcall/auth run test:auth`) - [x] `apps/api` (NestJS + Fastify): endpoints de auth, JwtAuthGuard, DomainExceptionFilter, rate limit de login via Redis (5/min por IP e por e-mail), helmet/cors, health checks — testado ponta a ponta com curl (login, refresh rotation, logout, RBAC, 401/403/429) - [ ] Password reset por e-mail — depende de SMTP configurado ## PHASE 08 — Extensions (agente.md secao 39-40, 178) - [x] Tabela `extensions` (tenant-scoped, RLS) — number, sip_password_enc, caller_id, context, sofia_profile, codecs, max_registrations - [x] `packages/shared/src/crypto.ts`: AES-256-GCM (senha SIP cifrada em repouso), `generateStrongPassword()`, `maskSecret()` - [x] `apps/api/src/extensions`: CRUD (POST/GET/GET:id/DELETE), RBAC via novo `PermissionGuard` genérico (`@RequirePermission`), tenant só do JWT - [x] Senha SIP só aparece em texto puro na resposta do POST, nunca depois (destructuring explícito, não spread — evita vazamento por acidente) - [x] `b2bcall-fs-config` resolve directory real: Tenant.telephonyDomain → Extension.number, decifra a senha, monta XML com dial-string - [x] `Tenant.telephonyDomain` fixo (`b2bcall.local`) via patch no `vars.xml` do FreeSWITCH — antes usava o IP dinâmico do container, instável - [x] HTTP Basic auth entre FreeSWITCH e fs-config (`gateway-credentials`, timingSafeEqual) — adicionada nesta mesma fase, não deixada pendente - [x] Testado ponta a ponta: criar ramal → `user/1500` dá USER_NOT_REGISTERED (achou, sem telefone) → deletar → volta a SUBSCRIBER_ABSENT - [x] Achado: `PermissionGuard` injetando `Reflector` via construtor dava `undefined` em runtime rodando via `tsx`/esbuild (emissão de metadata de tipo não é 100% confiável cross-file) — corrigido com `@Inject()` explícito; atenção pra isso em guards/services futuros - [ ] Quota de ramais — depende de Plans/Entitlements (não existe ainda) - [ ] Multi-domínio real por tenant — hoje só um domínio fixo pra todos ## PHASE 09 — Trunks (agente.md secao 41-42) - [x] Tabela `trunks` (tenant-scoped, RLS) — host/proxy/realm, register, username/password_enc (AES-256-GCM), dtmf_mode, ping, transport, status/status_updated_at - [x] `apps/api/src/trunks`: CRUD (POST/GET/GET:id/DELETE), mesmo padrão de RBAC/tenant de Extensions, senha nunca exposta em nenhum GET - [x] `packages/telephony`: `buildGatewayXml()` (XML de gateway Sofia) - [x] `b2bcall-fs-config`: gera `sip_profiles/external/.xml` (volume Docker compartilhado com o FreeSWITCH) e roda `sofia profile external rescan` via ESL — sincroniza no boot e sob demanda via Redis pub/sub (`b2bcall:trunks:sync`, publicado pela API a cada create/delete) - [x] Achado: 1º sync no boot corria antes da conexão ESL terminar de se estabelecer (erro cosmético) — corrigido com `FreeSwitchTelephonyProvider.waitUntilConnected()` - [x] Testado ponta a ponta com host fake: criar trunk → arquivo gerado → `sofia status gateway` mostra o gateway real (FAIL_WAIT, esperado) → deletar → arquivo removido (limpeza também tirou o `example.com` da vanilla que tinha sido copiado pro volume — comportamento correto) - [ ] **Lacuna real, não resolvida**: `Trunk.status` deveria ser atualizado via eventos `sofia::gateway_state` (código escrito em `apps/freeswitch-events/src/trunk-status.ts`, baseado no mesmo `normalizeEslEvent` já testado pra eventos CHANNEL_*), mas o evento **não foi observado chegando** em ~90s de monitoramento mesmo com o gateway mudando de estado de verdade no FreeSWITCH (FAIL_WAIT/DOWN). Os eventos `sofia::*` (CUSTOM) nunca foram provados funcionando nesta sessão — só CHANNEL_* foi verificado de ponta a ponta até agora. Precisa de investigação com um alvo SIP real (outro FreeSWITCH, por exemplo) antes de confiar em atualização automática de status em produção. Ver docs/TRUNKS.md. - [ ] Quota de troncos — depende de Plans/Entitlements (não existe ainda) ## PHASE 10 — Dialplan (agente.md secao 43-44) - [x] `dialplan_extensions` (tenant-scoped, RLS) — editor estruturado: context, condition field/expr, actions/anti-actions (JSON), continue, order, enabled - [x] `dialplan_versions` (tenant-scoped, RLS) — gerar/validar/versionar/ ativar; reativar versão antiga = rollback (sem endpoint separado) - [x] `apps/api/src/dialplan`: extensions CRUD + `versions/generate` + `versions/:id/activate`, permissions `freeswitch.view`/`.configure` - [x] Allowlist de applications seguras (`ALLOWED_DIALPLAN_APPLICATIONS`, sem `system`/`exec`/etc — agente.md secao 180) - [x] `b2bcall-fs-config` serve a versão ACTIVE dinamicamente por chamada (resolve tenant via `variable_b2bcall_tenant_id`, não domain — não sofre da limitação de multi-domínio do directory) - [x] Testado ponta a ponta: criar extension → gerar v1 → ativar → originate passando pelo dialplan de verdade → CALL_CREATED/ANSWERED/ENDED com tenantId correto. Criar v2 → ativar (v1 vira SUPERSEDED) → reativar v1 (rollback, v2 vira SUPERSEDED). Tudo confirmado via API. - [x] **ACHADO CRÍTICO, corrigido nesta fase**: testando a allowlist com `application: "system"`, a API aceitou (201) — `ValidationPipe` do Nest estava **completamente inoperante** em toda `apps/api` desde que ela foi criada (todo `@Body()`, todos os controllers) porque `tsx` (esbuild) não emite `design:paramtypes` corretamente pra tipos importados de outro arquivo, e o Nest pula validação silenciosamente quando não reconhece o tipo. Corrigido: `apps/api` agora builda com `tsc` de verdade antes de rodar (`tsc && tsx dist/main.js`) — nunca mais `tsx src/main.ts` direto. Ver docs/VALIDATION_PIPE_BUG.md. Reverificado com 2 testes deliberados pós-correção, ambos corretamente rejeitados com 400. - [ ] Só 1 condition por extension (simplificação) — FreeSWITCH suporta múltiplas em sequência, não implementado - [ ] `dialplan.view`/`.manage` não existem — reusei `freeswitch.*` ## PHASE 11+ — ver `agente.md` seções 37 em diante (Call Center/mod_callcenter, Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests) --- ## Riscos conhecidos - **RAM da VM (1.9GB total)**: medido com Postgres+Redis+FreeSWITCH rodando juntos — ~91MB no total (Postgres 37MB, Redis 10MB, FreeSWITCH 44MB), bem tranquilo. O risco real ainda não testado é o build/runtime do Next.js (frontend) e vários workers Node simultâneos — reavaliar quando chegarmos lá. - **Disco (26GB livre)**: build do FreeSWITCH + imagens Docker + gravações vão consumir espaço rápido. Monitorar com `df -h`.