Files
B2BCall-dialer/TODO.md
Matheus 2fb5010283 feat: Administração > Configurações, remover usuário do tenant, Discador > Callbacks
Fecha os últimos gaps do módulo Administração (agente.md secao 169): tela de
Configurações self-service do próprio tenant (GET/PATCH /tenant-settings, nunca
aceita tenantId arbitrário — só user.tenantId das claims), e DELETE /users/:id pra
remover alguém do tenant, com duas proteções que não existiam antes (não deixa
remover a si mesmo, não deixa remover/rebaixar o último Tenant Admin).

Corrige um bug real achado testando a remoção: o delete de TenantMembership (FORCE
RLS) rodava dentro de um prisma.$transaction([...]) em forma de array, que nunca
seta app.current_tenant_id — Prisma devolvia P2025 "not found" com a linha
existindo (500 pro cliente). Mesma classe de bug já corrigida antes em
TenantsController.create; corrigido com $transaction(async (tx) => ...) + set_config
explícito.

Adiciona Discador > Callbacks (GET/PATCH /leads/callbacks, tenant-wide): reagendar,
tentar de novo sem esperar, ou desistir de um lead que pediu retorno em outro
horário. Remove "Importações" do menu — decisão já registrada na PHASE 39 de não
duplicar uma tela pro que já existe (CSV em lote no wizard/detalhe da campanha).

Testado ponta a ponta via curl e Puppeteer contra o tenant Acme real: tenant-settings
GET/PATCH, proteção de último-admin nos dois endpoints que a usam, convite+remoção
de um admin temporário, e o fluxo completo de callback (lead forçado pra CALLBACK
via SQL, reagendar rejeitado pro passado/aceito pro futuro, requeue confirmado).

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

1567 lines
99 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TODO — B2BCall
## PHASE 01 — Infrastructure
- [x] Diagnóstico do servidor (Debian 13, 2 vCPU, ~1.9GB RAM, 26GB disco livre)
- [x] Docker + Docker Compose instalados
- [x] Estrutura de monorepo criada (apps/, packages/, infrastructure/, scripts/, docs/)
- [x] PostgreSQL 18 (docker-compose, porta 127.0.0.1:5432)
- [x] Redis 7 (docker-compose, porta 127.0.0.1:6379)
- [x] Secrets gerados em `.env` (POSTGRES_PASSWORD, REDIS_PASSWORD, JWT_SECRET, JWT_REFRESH_SECRET, ENCRYPTION_KEY, ESL_PASSWORD)
- [x] `FREESWITCH_PAT` configurado em `.env` (não commitado)
- [x] FreeSWITCH (imagem própria via pacotes SignalWire, não compilada da fonte —
ver docs/FREESWITCH.md; rodando, saudável, ~44MB RAM, senha ESL customizada,
nenhuma porta exposta ao host)
- [ ] nginx (reverse proxy)
## PHASE 05 — FreeSWITCH (agente.md secao 232)
- [x] Imagem própria (`infrastructure/freeswitch/`), pacotes SignalWire (PAT via
BuildKit secret, nunca na imagem final — verificado com `docker history`)
- [x] Módulos mínimos carregados: sofia, event_socket, commands, dptools,
callcenter, avmd, curl, local_stream, etc. (mod_xml_curl instalado mas
desativado até existir b2bcall-fs-config)
- [x] Senha do Event Socket trocada da padrão via entrypoint runtime (nunca
fica na imagem); porta 8021 não publicada no host
- [x] docs/FREESWITCH.md, docs/NETWORK_ARCHITECTURE.md (decisão de
network_mode adiada pra quando existir tronco SIP real)
- [ ] Diretório/dialplan ainda são os estáticos da config vanilla (ramais de
teste 1000-1019, senhas fracas) — substituir por mod_xml_curl na fase
Extensions/Trunks/Dialplan
## PHASE 06 — Event Socket (agente.md secao 21-25, 195)
- [x] `packages/telephony`: interface `TelephonyProvider` + `FreeSwitchTelephonyProvider`
(sobre a lib `esl`, reconexão com backoff já embutida na lib)
- [x] `normalizeEslEvent()`: eventos ESL crus → vocabulário interno (secao 24)
- [x] `apps/freeswitch-events` (b2bcall-fs-events): conexão ESL permanente,
resubscreve a cada reconexão, publica eventos normalizados no canal Redis
`b2bcall:events`
- [x] Achado: FreeSWITCH 1.11 aplica ACL implícita (só loopback) sem
`apply-inbound-acl` — bloqueava conexão de outro container mesmo com
senha certa. Corrigido com ACL própria cobrindo loopback + rede Docker.
- [x] Testado ponta a ponta com chamada loopback local: CALL_CREATED →
CALL_ANSWERED → CALL_ENDED corretos no Redis
- [ ] Reconciliação pós-reconexão (calls/agents/queues/registrations/gateways)
— não é possível ainda, sem essas tabelas persistidas
## PHASE 07 — XML Curl (agente.md secao 26)
- [x] `apps/freeswitch-config` (b2bcall-fs-config): responde ao protocolo
XML Curl do FreeSWITCH (POST form-encoded → XML), containerizado
- [x] `mod_xml_curl` reativado, binding restrito a `directory|dialplan`
(não `configuration` — evita chamadas HTTP desnecessárias no boot)
- [x] Por enquanto sempre "not found" (sem tabela extensions/dialplan ainda);
verificado que a config estática vanilla continua funcionando como
fallback (`user/8888` → SUBSCRIBER_ABSENT via fs-config,
`user/1000` → USER_NOT_REGISTERED via config estática — achou o usuário)
- [ ] Sem autenticação HTTP ainda — ok enquanto só responde "not found";
adicionar `gateway-credentials` antes de servir directory/dialplan reais
## PHASE 02 — SaaS Core
- [x] Monorepo Node.js/TypeScript (pnpm workspaces, tsconfig base)
- [x] Node 22 LTS + pnpm instalados no host
- [x] `packages/database` (Prisma 7 + driver adapter `pg`, migration inicial)
- [x] `packages/types` (TenantStatus, AgentState), `packages/shared`
- [x] Tabela `tenants` criada via migration (seção 29 do agente.md)
## PHASE 03 — Tenant Isolation
- [x] Tabelas `users` + `tenant_memberships` (tenant-scoped)
- [x] RLS (`ENABLE`/`FORCE ROW LEVEL SECURITY` + policy) em `tenant_memberships`
- [x] Tenant context via `set_config('app.current_tenant_id', ..., true)` (transaction-local)
- [x] Helper `withTenantContext()` em `packages/database`
- [x] Role de banco separado para runtime (`b2bcall_app`, sem SUPERUSER/BYPASSRLS) —
achado crítico: o role padrão do Docker Postgres é SUPERUSER e SEMPRE ignora RLS,
até com FORCE. Ver `docs/TENANT_ISOLATION.md`.
- [x] Teste automatizado de isolamento (`pnpm --filter @b2bcall/database run test:isolation`)
## PHASE 04 — Authentication / RBAC
- [x] `packages/auth`: hash Argon2id (`@node-rs/argon2`), JWT access token (`jose`),
refresh token opaco com rotation
- [x] Tabelas `roles`, `permissions`, `role_permissions`, `user_roles`, `sessions`, `audit_logs`
- [x] `login()` / `refreshSession()` / `logout()` / `listUserTenants()` / `setActiveTenant()`
- [x] `userHasPermission()` (RBAC com scope PLATFORM/TENANT)
- [x] Seed: catálogo de permissions + roles de sistema + Platform Super Admin inicial
(senha em `FIRST_LOGIN.txt`, fora do Git, `mustChangePassword=true`)
- [x] Teste automatizado (`pnpm --filter @b2bcall/auth run test:auth`)
- [x] `apps/api` (NestJS + Fastify): endpoints de auth, JwtAuthGuard, DomainExceptionFilter,
rate limit de login via Redis (5/min por IP e por e-mail), helmet/cors, health checks
— testado ponta a ponta com curl (login, refresh rotation, logout, RBAC, 401/403/429)
- [ ] Password reset por e-mail — depende de SMTP configurado
## PHASE 08 — Extensions (agente.md secao 39-40, 178)
- [x] Tabela `extensions` (tenant-scoped, RLS) — number, sip_password_enc,
caller_id, context, sofia_profile, codecs, max_registrations
- [x] `packages/shared/src/crypto.ts`: AES-256-GCM (senha SIP cifrada em
repouso), `generateStrongPassword()`, `maskSecret()`
- [x] `apps/api/src/extensions`: CRUD (POST/GET/GET:id/DELETE), RBAC via novo
`PermissionGuard` genérico (`@RequirePermission`), tenant só do JWT
- [x] Senha SIP só aparece em texto puro na resposta do POST, nunca depois
(destructuring explícito, não spread — evita vazamento por acidente)
- [x] `b2bcall-fs-config` resolve directory real: Tenant.telephonyDomain →
Extension.number, decifra a senha, monta XML com dial-string
- [x] `Tenant.telephonyDomain` fixo (`b2bcall.local`) via patch no `vars.xml`
do FreeSWITCH — antes usava o IP dinâmico do container, instável
- [x] HTTP Basic auth entre FreeSWITCH e fs-config (`gateway-credentials`,
timingSafeEqual) — adicionada nesta mesma fase, não deixada pendente
- [x] Testado ponta a ponta: criar ramal → `user/1500` dá USER_NOT_REGISTERED
(achou, sem telefone) → deletar → volta a SUBSCRIBER_ABSENT
- [x] Achado: `PermissionGuard` injetando `Reflector` via construtor dava
`undefined` em runtime rodando via `tsx`/esbuild (emissão de metadata
de tipo não é 100% confiável cross-file) — corrigido com `@Inject()`
explícito; atenção pra isso em guards/services futuros
- [ ] Quota de ramais — depende de Plans/Entitlements (não existe ainda)
- [ ] Multi-domínio real por tenant — hoje só um domínio fixo pra todos
## PHASE 09 — Trunks (agente.md secao 41-42)
- [x] Tabela `trunks` (tenant-scoped, RLS) — host/proxy/realm, register,
username/password_enc (AES-256-GCM), dtmf_mode, ping, transport,
status/status_updated_at
- [x] `apps/api/src/trunks`: CRUD (POST/GET/GET:id/DELETE), mesmo padrão de
RBAC/tenant de Extensions, senha nunca exposta em nenhum GET
- [x] `packages/telephony`: `buildGatewayXml()` (XML de gateway Sofia)
- [x] `b2bcall-fs-config`: gera `sip_profiles/external/<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)
- [x] Achado: 1º sync no boot corria antes da conexão ESL terminar de se
estabelecer (erro cosmético) — corrigido com
`FreeSwitchTelephonyProvider.waitUntilConnected()`
- [x] Testado ponta a ponta com host fake: criar trunk → arquivo gerado →
`sofia status gateway` mostra o gateway real (FAIL_WAIT, esperado) →
deletar → arquivo removido (limpeza também tirou o `example.com` da
vanilla que tinha sido copiado pro volume — comportamento correto)
- [ ] **Lacuna real, não resolvida**: `Trunk.status` deveria ser atualizado
via eventos `sofia::gateway_state` (código escrito em
`apps/freeswitch-events/src/trunk-status.ts`, baseado no mesmo
`normalizeEslEvent` já testado pra eventos CHANNEL_*), mas o evento
**não foi observado chegando** em ~90s de monitoramento mesmo com o
gateway mudando de estado de verdade no FreeSWITCH (FAIL_WAIT/DOWN).
Os eventos `sofia::*` (CUSTOM) nunca foram provados funcionando nesta
sessão — só CHANNEL_* foi verificado de ponta a ponta até agora.
Precisa de investigação com um alvo SIP real (outro FreeSWITCH, por
exemplo) antes de confiar em atualização automática de status em
produção. Ver docs/TRUNKS.md.
- [ ] Quota de troncos — depende de Plans/Entitlements (não existe ainda)
## PHASE 10 — Dialplan (agente.md secao 43-44)
- [x] `dialplan_extensions` (tenant-scoped, RLS) — editor estruturado:
context, condition field/expr, actions/anti-actions (JSON), continue,
order, enabled
- [x] `dialplan_versions` (tenant-scoped, RLS) — gerar/validar/versionar/
ativar; reativar versão antiga = rollback (sem endpoint separado)
- [x] `apps/api/src/dialplan`: extensions CRUD + `versions/generate` +
`versions/:id/activate`, permissions `freeswitch.view`/`.configure`
- [x] Allowlist de applications seguras (`ALLOWED_DIALPLAN_APPLICATIONS`,
sem `system`/`exec`/etc — agente.md secao 180)
- [x] `b2bcall-fs-config` serve a versão ACTIVE dinamicamente por chamada
(resolve tenant via `variable_b2bcall_tenant_id`, não domain — não
sofre da limitação de multi-domínio do directory)
- [x] Testado ponta a ponta: criar extension → gerar v1 → ativar → originate
passando pelo dialplan de verdade → CALL_CREATED/ANSWERED/ENDED com
tenantId correto. Criar v2 → ativar (v1 vira SUPERSEDED) → reativar v1
(rollback, v2 vira SUPERSEDED). Tudo confirmado via API.
- [x] **ACHADO CRÍTICO, corrigido nesta fase**: testando a allowlist com
`application: "system"`, a API aceitou (201) — `ValidationPipe` do
Nest estava **completamente inoperante** em toda `apps/api` desde que
ela foi criada (todo `@Body()`, todos os controllers) porque `tsx`
(esbuild) não emite `design:paramtypes` corretamente pra tipos
importados de outro arquivo, e o Nest pula validação silenciosamente
quando não reconhece o tipo. Corrigido: `apps/api` agora builda com
`tsc` de verdade antes de rodar (`tsc && tsx dist/main.js`) — nunca
mais `tsx src/main.ts` direto. Ver docs/VALIDATION_PIPE_BUG.md.
Reverificado com 2 testes deliberados pós-correção, ambos
corretamente rejeitados com 400.
- [ ] Só 1 condition por extension (simplificação) — FreeSWITCH suporta
múltiplas em sequência, não implementado
- [ ] `dialplan.view`/`.manage` não existem — reusei `freeswitch.*`
## PHASE 11 — mod_callcenter / Queues (agente.md secao 37, 50-51)
- [x] 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
- [x] `queues` table (tenant-scoped, RLS) — strategy, moh/announce,
wait times, tier rules, discard/abandoned, skip-external-calls,
recording_enabled
- [x] `packages/telephony`: `buildQueueXml()`
- [x] `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)
- [x] `apps/api/src/queues`: CRUD (POST/GET/GET:id/DELETE), permissions
`queues.view`/`.manage`
- [x] `b2bcall-fs-config` (`queue-sync.ts`): 1 arquivo por fila (volume
compartilhado), sincroniza via Redis pub/sub (`b2bcall:queues:sync`)
- [x] 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
- [x] 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)
- [x] `agents`/`tiers`/`agent_sessions`/`agent_state_events`/
`pause_reasons`/`agent_pause_events` (tenant-scoped, RLS) — separa
User/Agent/Extension (secao 45)
- [x] 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.
- [x] 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`
- [x] 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)
- [x] `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)
- [x] 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.
- [x] 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)
- [x] 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)
- [x] **Bug real, achado nesta fase**: nenhum evento CUSTOM do ESL
(`sofia::register`, `sofia::gateway_state`, `callcenter::info`) jamais
chegava em `b2bcall-fs-events` — `event_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.
- [x] 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`
- [x] 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
- [x] `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
- [x] `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)
- [x] `RealtimeRedisBridge`: assina `b2bcall:events` (canal único, mesmo
usado desde Event Socket), reencaminha pro tenant certo
- [x] `AGENT_STATE_CHANGED` publicado direto de `agents-me.controller.ts`
(login/logout/pause/resume) — tenantId já vem do JWT, sem fan-out
- [x] 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)
- [x] 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`)
- [x] `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)
- [x] 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)
- [x] `packages/entitlements` (pacote novo): `assertQuota`/
`assertFeatureEnabled`, erros mapeados pra 403 no
`DomainExceptionFilter`
- [x] Retrofit nos controllers existentes: Extensions/Trunks/Agents/Queues
agora contam linhas ativas e checam quota antes de criar
- [x] 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)
- [x] `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`")
- [x] 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
- [x] `packages/shared/src/phone.ts` (secao 70): normalização dedicada,
preparada pra E.164 completo, só BR implementado por enquanto
- [x] 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}
- [x] Lista de bloqueio (secao 71): CRUD tenant-scoped
- [x] 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)
- [x] 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)
- [x] 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
- [x] 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
- [x] Reserva atômica de leads (secao 78): `FOR UPDATE SKIP LOCKED` raw
SQL dentro da mesma transação `withTenantContext`
- [x] `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
- [x] 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
- [x] 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
- [x] 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)
- [x] **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.
- [x] **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`.
- [x] `GET /campaigns/:id/stats` (secao 227.7 "visualizar pacing"):
CampaignStats + contagem de agentes por estado + calls em andamento
- [x] 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)
- [x] `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
- [x] `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)
- [x] **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).
- [x] **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.
- [x] `GET /reports/queues` (secao 159): recebidas/atendidas/abandonadas/
TME/TMA/Service Level/Abandon Rate por fila
- [x] `GET /reports/agents` (secao 158): tempo logado/pausado/por estado
(AgentStateEvent pareado) + chamadas atendidas/TMA
- [x] `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)
- [x] `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)
- [x] 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)
- [x] `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
- [x] `buildRecordingObjectKey` (secao 93):
`tenants/{tenant_id}/recordings/YYYY/MM/DD/{call_id}.wav`, sempre
montada no servidor
- [x] 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`)
- [x] `apps/predictive-dialer`: `RECORD_STEREO=true` +
`execute_on_answer='record_session ...'` quando
`Campaign.recordingEnabled` — `origination_uuid` pré-gerado pra
poder montar o path de gravação antes do originate
- [x] `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
- [x] `GET /recordings`, `GET /recordings/:id`, `GET /recordings/:id/audio`
(stream autenticado, nunca URL direta pro storage)
- [x] 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)
- [x] **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.
- [x] 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)
- [x] 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
- [x] `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)
- [x] **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)
- [x] `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`)
- [x] **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.
- [x] **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)`.
- [x] 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
- [x] 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
- [x] `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)
- [x] `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`
- [x] `packages/entitlements`: `isFeatureEnabled` (versão não-lançante de
`assertFeatureEnabled`, pra decisões em background)
- [x] `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`)
- [x] `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
- [x] 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)
- [x] 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
- [x] `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`)
- [x] Novo `AIJobType.SCORECARD_EVALUATION` (migration
`20260828194146_ai_job_scorecard_evaluation`, só `ALTER TYPE ...
ADD VALUE`, sem RLS pra mexer)
- [x] `apps/api/src/quality/quality-scorecards.controller.ts`: CRUD
(create com items aninhados numa tacada só, list, soft delete)
- [x] `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
- [x] 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)
- [x] `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
- [x] 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
- [x] 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
- [x] `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
- [x] 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)
- [x] 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)
- [x] **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`.
- [x] **Bug real, achado no teste desta fase**: `SubscriptionsController`
criava/lia `TenantSubscription` sem `withTenantContext` — RLS
rejeitava o create e o list sempre voltava vazio. Corrigido.
- [x] 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
`RatedUsageItem`s (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
- [x] 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)
- [x] `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
- [x] 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)
- [x] 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)
- [x] 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)
- [x] 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)
- [x] 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
- [x] **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).
- [x] **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.
- [x] 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)
- [x] **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`).
- [x] `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.
- [x] `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`.
- [x] 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.
- [x] **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.
- [x] `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).
- [x] Dashboard do tenant (`/app`) consumindo esse endpoint, mesmo
`InstrumentTile`/ghost-dash da PHASE 23.
- [x] 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,
178)
- [x] `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.
- [x] 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.
- [x] **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.
- [x] 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)
- [x] 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).
- [x] 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).
- [x] 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).
- [x] **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.
- [x] **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.
- [x] 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)
- [x] 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
- [x] `lib/callcenter-types.ts` (novo): tipos `Queue`/`Trunk`/`PauseReason`/
`Disposition`/`SuppressionEntry` compartilhados entre as 5 telas
- [x] `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`
- [x] `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
- [x] **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.
- [x] 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)
- [x] **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).
- [x] **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.
- [x] **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.
- [x] 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).
- [x] 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.
- [x] 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)
- [x] **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.
- [x] 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.
- [x] 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).
- [x] 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.
- [x] **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)
- [x] 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.
- [x] 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.
- [x] 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)
- [x] 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.
- [x] 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.
- [x] 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).
- [x] `Textarea` novo em `components/ui/input.tsx` (mesmo estilo de
`Input`/`Select`) — primeira tela que precisa de texto longo
(conteúdo de prompt).
- [x] 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)
- [x] **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).
- [x] `/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.
- [x] 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).
- [x] `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.
- [x] 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)
- [x] **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).
- [x] `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).
- [x] **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.
- [x] 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).
- [x] 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)
- [x] `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).
- [x] `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.
- [x] `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).
- [x] **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.
- [x] 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").
- [x] 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)
- [x] **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).
- [x] 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).
- [x] **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.
- [x] 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,
168)
- [x] `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.
- [x] 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.
- [x] 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)
- [x] **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).
- [x] `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.
- [x] 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.
- [x] 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)
- [x] `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`.
- [x] **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.
- [x] **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.
- [x] 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.
- [x] 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.
- [x] **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)
- [x] `/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.
- [x] `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).
- [x] 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)
- [x] `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)
- [x] Tela `/app/administracao/configuracoes` — formulário + bloco
read-only, mesmo padrão visual das outras telas de Administração
- [x] `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
- [x] 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)
- [x] 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)
- [x] `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)
- [x] Tela `/app/discador/callbacks` — lista com nome da campanha,
telefone, tentativas, "remarcado para" com date-time picker inline
- [x] "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
- [x] 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
---
## 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.