Tela /app/telefonia/ramais completa: listagem com busca/ordenacao, criacao com reveal de senha SIP (gerada pela API, mostrada uma unica vez), detalhe com redefinir senha (novo POST /extensions/:id/reset-password — unica forma de "editar" a senha, nunca reexpoe a existente) e remover (soft delete), confirmacao inline de 2 cliques em vez de modal. Cada tela deixa explicito de qual tenant os ramais sao, alem do isolamento por RLS que ja existia. Corrige de quebra um bug real que afeta qualquer acao futura sem payload: apiFetch sempre mandava Content-Type: application/json mesmo em requests sem body, e o parser do Fastify rejeita body vazio com esse header. Testado ponta a ponta com um tenant real (Acme Call Center): criar ramal, revelar senha, redefinir (senha nova confirmada diferente da original), remover, lista voltando vazia. Dark mode e mobile conferidos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
827 lines
52 KiB
Markdown
827 lines
52 KiB
Markdown
# 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+ — ver `agente.md` seções 140 em diante (resto do Frontend,
|
|
Security, Tests)
|
|
|
|
---
|
|
|
|
## Riscos conhecidos
|
|
- **RAM da VM (1.9GB total)**: medido com Postgres+Redis+FreeSWITCH rodando juntos —
|
|
~91MB no total (Postgres 37MB, Redis 10MB, FreeSWITCH 44MB), bem tranquilo. O risco
|
|
real ainda não testado é o build/runtime do Next.js (frontend) e vários workers Node
|
|
simultâneos — reavaliar quando chegarmos lá.
|
|
- **Disco (26GB livre)**: build do FreeSWITCH + imagens Docker + gravações vão consumir
|
|
espaço rápido. Monitorar com `df -h`.
|