Commit Graph

71 Commits

Author SHA1 Message Date
6c23513f42 fix(frontend): cursor de seta nos cantos do FAB do widget (border-radius exclui hit-test)
Pedido do usuário: "vira a mão quando está sobre a letra e não sobre o
retângulo inteiro". O FAB flutuante é rounded-full 56x56 — Chrome exclui
os cantos cortados desse quadrado do hit-test de elementos com
border-radius, então o mouse nos cantos caía pro <div> de posicionamento
por trás (sem cursor:pointer) em vez do botão. Clique dentro do círculo
real sempre funcionou; só o cursor ficava inconsistente. Confirmado com
hover real via Playwright piercing o shadow DOM. Fix: cursor-pointer no
wrapper.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 16:40:22 -03:00
dd095077dd fix(frontend): widget usava config antigo do localStorage em vez do ramal real do agente
Dois bugs reais reportados pelo usuário testando um ramal de verdade
(teste@teste.com.br / ramal 1501):

1. widgetStorage.mergeConfig fazia `{...externalConfig, ...stored}` —
   um sip_config salvo de sessão/ramal anterior no mesmo navegador
   sempre vencia sobre as credenciais que o backend acabou de injetar
   via data-sip-* pro agente logado agora, então o widget tentava
   registrar com o ramal errado (ou senha vazia) pra sempre, sem erro
   visível. Invertido: externalConfig (sempre fresco, vindo do agente
   logado) vence; stored só preenche o que faltar.

2. crypto.subtle é undefined em contexto inseguro (HTTP puro, mesma
   classe de bug do botão de copiar da PHASE 71) — salvar a senha na
   aba Configurações do widget quebrava com "Cannot read properties of
   undefined (reading 'importKey')". Agora cai pra um fallback base64
   reversível quando SubtleCrypto não está disponível.

Testado com Playwright: config "errado" salvo no localStorage não sobrevive
mais a um reload — o próximo connect() usa o ramal real do agente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 16:14:16 -03:00
3fe571bfb0 feat(telefonia): edição de ramal (nome/caller ID/contexto/codecs/grupo) junto da senha SIP
Pedido do usuário: abrir a edição completa do ramal no mesmo lugar de
ver/redefinir a senha, em vez de só um campo (grupo de captura) editável
isoladamente. UpdateExtensionDto ganha name/context/codecs (só callGroup
e caller ID eram editáveis antes); codecs valida contra uma lista fixa
e agora realmente vira absolute_codec_string no directory XML — antes
a coluna existia no schema mas não tinha efeito nenhum no FreeSWITCH.

Grupo de captura vira um input com datalist alimentado pelos grupos já
usados em outros ramais do tenant, em vez de digitar de novo do zero.

Testado ponta a ponta com Playwright (tenant/ramais descartáveis):
grupo reaproveitado de outro ramal, edição de nome/caller ID/contexto,
remoção de um codec — tudo persistido e refletido na tela depois de
salvar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 14:01:03 -03:00
e75872faec fix(frontend): widget do softphone preso em cinza — dupla chamada de connect() sem cooldown
Dois efeitos de auto-connect do Widget.jsx (mount + reconnect-on-drop)
podiam disparar connect() em sequência rápida sem respeitar o cooldown
de 5s (só o efeito de reconexão gravava o timestamp), abrindo um
segundo SimpleUser/registro concorrente que sip.js rejeita com
RequestPendingError — e o catch sobrescrevia connectionStatus pra
"disconnected" incondicionalmente, travando o widget em cinza.
Trava por promise em useSip.js::connect() + cooldown também no
auto-connect de mount resolvem: testado com Playwright dentro do app
real, connect() dispara uma única vez e o status nunca mais volta
pra cinza.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 13:46:52 -03:00
9d3c84064f fix(ui): botão de copiar senha não fazia nada em HTTP puro (fora de localhost)
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
2026-08-31 13:11:03 -03:00
b33d35db50 fix(softphone): widget nunca calculava o AOR quando config vinha via data-sip-* — ficava preso em "desconectado" pra sempre
Usuário configurou o proxy WebRTC de QA de verdade (Platform > Sistema >
Softphone WebRTC) e tentou registrar o ramal 1501 (tenant teste01) — o
widget só ficava cinza, sem nenhum erro visível.

Descartado infraestrutura/credenciais primeiro (testes diretos: DNS, TCP,
TLS, handshake WebSocket manual, e um REGISTER SIP cru com autenticação
digest através do proxy — chegou no FreeSWITCH de verdade e voltou 200 OK).
O bug estava no código-fonte do próprio widget: `widgetStorage.mergeConfig`
só calculava o campo `aor` (sip:user@domain) no branch de config salva
manualmente no localStorage — no branch usado quando a config vem via
data-sip-*/window.HandphoneConfig (sempre, neste app), `aor` nunca era
calculado, e `connect()` quebrava em `cfg.aor.startsWith(...)` com `aor`
undefined — erro só no console, nunca visível pro usuário.

Corrigido na mesma cópia local do handphone-2.0 (nunca enviado pro repo
externo do usuário), rebuildado e revendorizado. Testado ponta a ponta com
Playwright + navegador real fora do app (data-sip-* do ramal 1501 de
verdade, sem depender do login do tenant): REGISTER 200 OK, botão do
widget vira verde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 13:03:38 -03:00
5c23b105b3 feat(auth): aumenta sessão web pra 12h (pedido do usuário: call center, agente não pode cair no meio do turno)
Access token JWT (packages/auth/src/tokens.ts) e o teto absoluto do cookie
de sessão (duplicado em login/post-login/select-tenant/middleware) subiram
de 15min/8h pra 12h. O refresh silencioso do middleware continua existindo
e deslizando a sessão pra frente a cada requisição — 12h agora é 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.

Verificado com login real: token decodificado mostra exp-iat = 12h exatas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 12:48:33 -03:00
432cd55adb feat(ivr): dropdown de destino (fila/outro IVR) numa opção de menu, com bloqueio de ciclo (PHASE 67)
Mesmo gap que Rotas de Entrada já tinha resolvido na PHASE 62: uma opção
dentro de um menu de IVR só sabia apontar pra ramal (bridge fixo). Novo
IvrMenuOption.destinationType (EXTENSION/QUEUE/IVR) decide a action de
cada branch em buildIvrDialplanExtensions, mesmo padrão de
buildInboundRouteXml — QUEUE vira answer+callcenter, IVR vira transfer
pro IVR_ENTRY_DESTINATION do menu alvo (efetivamente um sub-menu).

Sem CALL_GROUP aqui de propósito: diferente de InboundRoute (resolvido a
cada chamada), o dialplan de um IVR é compilado uma vez ao salvar —
"quem está no grupo agora" ficaria desatualizado até a próxima edição.

"Outro IVR" cria a primeira forma de um menu apontar pra outro (uma
InboundRoute nunca é ela mesma um menu, nunca formava ciclo antes).
assertNoIvrCycle monta o grafo com todos os menus do tenant antes de
compilar e rejeita qualquer save que criaria um ciclo, em create e update.

Os dois editores de IVR (form clássico e o editor visual de nós) ganharam
o mesmo par de dropdowns "Tipo"/"Destino" já usado em Rotas de Entrada.

Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR reais
criados na hora: menu com opção Fila e menu com opção Outro IVR apontando
pro primeiro, os dois persistindo certo depois de reload completo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 09:41:22 -03:00
97ef8a6ba8 feat: edição de rotas de entrada, fix de 2 bugs reais no ESL, diagnóstico de NAT/áudio e softphone WebRTC (PHASE 65/66)
Três achados reportados pelo usuário numa mensagem só: (1) Rotas de Entrada
não tinha edição depois de criada — implementada no mesmo padrão de Filas;
(2) telas de Platform > Infraestrutura sempre davam "Timeout no ESL" nesta
VM — não era limitação permanente como o comentário antigo dizia, e sim
ESL_HOST=freeswitch (nome DNS que só existe dentro da rede do Docker) mais
um segundo bug independente (`show gateways as json` não é comando válido
nesta versão do FreeSWITCH); (3) ramal externo registrava mas sem áudio —
diagnosticado com contadores de pacote do iptables: a VM está atrás de um
roteador sem port-forward pra faixa de RTP, achado de infraestrutura de
rede, não bug de código.

Também integra o softphone WebRTC (handphone.js/OpenSIPS, já em produção):
código-fonte encontrado em git.falehandix.com.br/Handix/handphone-2.0,
patch mínimo pra aceitar o endereço do proxy em runtime (era build-time),
nova config global (Platform > Infraestrutura > Softphone WebRTC) e widget
na topbar do tenant que pega usuário/domínio/senha do ramal vinculado ao
agente logado.

Adiciona docs/QA_SETUP.md — runbook completo pra subir o ambiente do zero
numa máquina nova (Docker, migrations, seed, systemd), e completa o
.env.example que estava faltando a maioria das variáveis reais.

Testado ponta a ponta com Playwright: edição de rota (criar/editar/F5),
as 3 telas de Infraestrutura com dado real, e um tenant/ramal/agente de
teste criados na hora confirmando que o script do softphone recebe as
credenciais certas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 22:14:39 -03:00
a779a7e51f feat(nav): arrastar-e-soltar pra reordenar o menu, persistindo entre sessões
Pedido do usuário: "testa arrastando um sub menu pra ver se persiste" —
o acordeão da fase anterior só tinha abrir/fechar, nada de arrastar.
Perguntei o que ele queria dizer com "arrastar" antes de supor, e a
resposta foi: quer a funcionalidade de verdade.

@dnd-kit (core + sortable) — cada grupo do menu 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). Alça de arrastar
dedicada (GripVertical) em vez do item inteiro, pra não conflitar com o
clique de abrir/fechar acordeão ou navegar pelo link.

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), sem colisão de rótulos entre eles.

Achado real testando: aplicar a ordem salva direto no inicializador do
useState diverge do HTML que o servidor mandou (SSR nunca tem acesso a
localStorage) e disparava "Hydration failed" no React — inofensivo no
resultado final, mas um erro real 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) — o mesmo padrão que o
ThemeToggle já usava e eu não tinha replicado.

Testado com Playwright de verdade em dois cenários: arrastar um grupo
inteiro (ordem mudou e sobreviveu a um F5 completo) e arrastar um
sub-item dentro de "Discador" (reordenou só ali dentro, confirmado que
os outros grupos continuaram intactos, e também sobreviveu ao F5).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 20:34:46 -03:00
64ef0892ed feat(nav): menu em acordeão — cada grupo abre/fecha independente
Pedido do usuário: "usar o modelo de arcordeon no menu para que eu
poder reocler cada sub menu" — os 6 grupos com sub-itens (Discador,
Call Center, Telefonia, IA, Relatórios, Administração) ficavam sempre
todos abertos ao mesmo tempo, sem jeito de recolher.

NavList (compartilhado entre sidebar desktop, drawer mobile, Platform e
Tenant) ganhou estado de abrir/fechar por seção — não é exclusivo
(abrir um grupo não fecha os outros). Chevron gira 180° indicando
estado; aria-expanded no botão do cabeçalho. Default: só o grupo que
contém a rota atual começa aberto, os demais começam fechados —
resolve "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, já que o layout não remonta em
troca de rota client-side).

Testado com Playwright de verdade: seção sem rota ativa começa
aria-expanded="false", clicar abre, clicar de novo fecha.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 20:14:16 -03:00
738bfa4f36 feat(rotas-entrada): dropdown de destino real (ramal/IVR/fila/grupo)
Pedido do usuário: "em vez de o campo de destino ser aberto tem que ter
um dropdown listando todos os ramais do tennant e tb todas ivr e filas
e grupos de ramais do tennant" — até aqui o destino era texto livre.

Construir o dropdown revelou que fila e grupo NUNCA tinham sido
implementados de verdade como destino possível — só ramal/IVR
funcionavam. Oferecer as duas opções sem o mecanismo por trás seria
mostrar um dropdown mentiroso, então:

InboundRoute.destinationType (novo enum EXTENSION/IVR/QUEUE/CALL_GROUP)
decide como buildInboundRouteXml interpreta o destino:
- EXTENSION/IVR: inalterado, o mesmo transfer já testado (PHASE 56/58).
- QUEUE: destinationNumber guarda o Queue.id; a resolução de entrada
  emite `answer` + `callcenter data="<queueId>@<domain>"` direto, sem
  tocar no dialplan "default". `callcenter` entrou no allowlist de
  applications com o mesmo risco zero de `pickup`.
- CALL_GROUP: destinationNumber guarda o Extension.callGroup; a
  resolução consulta AGORA (nunca um snapshot salvo) todos os ramais
  com esse callGroup e emite um `bridge` multi-leg — toca todos ao
  mesmo tempo, quem atender primeiro cancela os outros. Trocar quem
  está no grupo depois de criar a rota já vale na próxima chamada.

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; grupo — 2
ramais reais no mesmo callGroup, a chamada tocou nos DOIS ao mesmo
tempo (mesmo call_uuid, ambas RINGING), atender em um cancelou o outro
automaticamente — ring group de verdade.

Tela: "Tipo de destino" + um segundo dropdown com as opções reais do
tenant pra cada tipo (ramais, menus de IVR, filas, ou os valores
distintos de callGroup já usados em algum ramal). Testado com
Playwright: os 4 tipos aparecem, e trocar o tipo atualiza as opções do
segundo dropdown corretamente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 20:13:55 -03:00
1b05d93c48 feat(agents): widget de status na topbar (entrar/pausar/retomar/sair)
Achado real reportado pelo usuário: "não achei como deixar o agente
online, tem algum perfil ou tipo de usuario especifico?" — não era
permissão nenhuma. POST /agents/me/login|pause|resume|logout sempre
funcionaram sem exigir permissão (só JwtAuthGuard, resolvem "meu
agente" pelo userId do próprio token). O problema: nenhuma tela chamava
esses endpoints — só existia o CRUD admin de Agentes (criar o vínculo
usuário+ramal), nunca um controle pro próprio usuário logado.

Adicionado GET /agents/me (404 = usuário sem Agent neste tenant, não um
erro — é assim que o widget decide se aparece) 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).

AgentStatusWidget na topbar (visível em qualquer página do app do
tenant) só aparece pra quem tem Agent vinculado: Offline -> Entrar ->
Disponível -> Pausar (escolhe motivo) -> Em pausa -> Retomar/Sair.

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.

Testado ponta a ponta com Playwright, logado como um agente de teste
real (usuário novo, role "agent", Agent vinculado a um ramal real): o
fluxo completo (entrar/pausar/retomar/sair) funciona pela tela, sem
nenhuma chamada de API manual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 20:13:29 -03:00
26cc79cec2 fix(filas): adiciona edição de fila após criação
Achado real reportado pelo usuário: "na fila nao tem opcao de editar a
fila apos a criacao". queues.controller.ts só tinha create/list/delete
— nunca existiu PATCH.

UpdateQueueDto (mesmos campos do create, todos opcionais) + PATCH
/queues/:id. Tela ganhou um botão "Editar" por linha, abrindo um
formulário inline pré-preenchido (reaproveita o mesmo componente do
"Nova fila", só muda entre createQueue/updateQueue).

Testado ponta a ponta com Playwright de verdade (não só a API): abrir o
form de edição confirmando que veio pré-preenchido com os valores reais
da fila, editar nome e espera máxima, salvar, e confirmar que a tabela
atualizou com os novos valores.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 20:13:03 -03:00
5bbe9e6800 feat(ivr): persiste posição dos nós do editor visual entre recargas
Pedido do usuário: "faz a posição dos nós persistir entre recargas" —
o editor visual da PHASE 60 recalculava o layout do zero a cada carga
da página, perdendo qualquer arrasto manual assim que a tela recarregava.

`IvrMenu.entryPositionX/Y` + `IvrMenuOption.positionX/Y` (nullable,
puramente de apresentação — nunca entram no dialplan compilado, só no
layout do canvas). Salvos no banco, nunca localStorage: mesma convenção
do resto do app, estado compartilhado entre quem quer que edite o
tenant, não por navegador/dispositivo. "Salvar alterações" agora lê a
posição de verdade do estado de nós do @xyflow/react (reflete arrastos
feitos na sessão), não do estado de conteúdo das opções — os dois
tinham ficado dessincronizados desde a PHASE 60. Sem posição salva ainda
(menu novo, opção recém-adicionada), continua caindo num layout
automático em coluna.

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 com uma sessão real (cookie de login, não só a API
crua) já embute essas coordenadas nos props iniciais do componente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 18:33:19 -03:00
0148b926a0 feat(ivr): editor visual do menu (canvas de nós, sem Node-RED)
Pedido do usuário: "ajusta o IVR para fazer o fluxo de maneira visual
usando nodeRED". Perguntei antes de construir: integrar Node-RED de
verdade significa rodar uma plataforma externa completa (motor de
execução próprio, nós "function" = execução de código arbitrário — o
mesmo tipo de risco corrigido no dialplan nesta sessão — e sem
multi-tenancy nativa), um projeto de vários dias com decisões de
arquitetura antes de começar a construir. O usuário escolheu a
alternativa: um editor visual próprio, sem dependência externa.

`@xyflow/react` (sucessor mantido do reactflow) — 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 pro canvas). "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 (o modelo
IvrMenu/IvrMenuOption continua sendo a fonte da verdade).

Testado ponta a ponta pela tela de verdade, não só leitura de código:
criado um menu real, enviado um prompt de VOZ real (WAV sintetizado com
espeak-ng dizendo uma saudação de verdade, não um tom sintético como nos
testes anteriores desta fase) e completada uma chamada real — a saudação
de ~6s tocou até o fim, o dígito foi capturado, 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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 18:21:26 -03:00
486c39803a feat(ivr): upload de áudio pro prompt do IVR
Pedido do usuário: "adiciona upload de áudio pro prompt do IVR" — até
aqui o prompt era só texto livre (na prática, sempre um tom padrão,
nunca voz de verdade).

`POST /ivr-menus/:id/prompt` (multipart via @fastify/multipart —
primeiro upload de arquivo binário desta API) só aceita WAV (cabeçalho
RIFF/WAVE validado antes de gravar; esta implantação do FreeSWITCH não
tem mod_shout, então MP3 nunca funcionaria de qualquer forma). Gravado
num bind mount NOVO (./data/ivr-prompts no host ↔ /ivr-prompts no
container freeswitch) — mesma convenção já usada 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 que o usuário sobe, o FreeSWITCH lê ao vivo
durante play_and_get_digits. Um fetch em rede (S3/HTTP) durante uma
chamada ativa foi descartado de propósito — latência/confiabilidade
desnecessárias pra um prompt de poucos segundos.

GET /ivr-menus/:id/prompt (autenticado) serve o preview — mesmo
princípio do player de gravações, nunca uma URL direta pro storage.
Achado real corrigido antes de commitar: minha primeira versão do
delete de menu deixava o .wav órfão no disco — agora deletar o menu ou
trocar/remover o prompt sempre limpa o arquivo, confirmado com um teste
real de upload+delete.

Tela "Telefonia > IVR" ganhou upload/troca/remoção de áudio por menu +
player de preview.

Testado ponta a ponta com um WAV real de 44.1kHz/mono (não o formato
"nativo" de telefonia, de propósito, pra confirmar que funciona com o
que uma pessoa qualquer gravaria): 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 de destino.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 17:51:31 -03:00
36e85c1abf feat(ivr): tela de autoria de menu de IVR no frontend
Pedido do usuário: "constrói a tela de IVR no frontend". Até aqui um
menu de IVR só existia se alguém escrevesse as regras à mão no editor
genérico de dialplan (o que eu fiz manualmente pra testar na PHASE 56) —
sem UI nenhuma pra isso.

`IvrMenu`/`IvrMenuOption` (RLS real): nome + contexto (derivado do nome)
+ opções (dígito → ramal + rótulo opcional). Nenhuma tabela nova pro
dialplan em si — `IvrMenusController` compila o menu inteiro em
`DialplanExtension`/`DialplanVersion` do contexto do menu
(`buildIvrDialplanExtensions`, packages/telephony) usando as MESMAS 2
formas de `<extension>` já testadas com DTMF real na PHASE 56 (entrada
com `play_and_get_digits` + `transfer` usando o dígito coletado como
novo destination_number, uma extension por dígito) — e já gera + ativa
a versão nova automaticamente, o mesmo generate+activate manual que o
editor de dialplan faz, só que embutido no create/update do menu.

`greeting` (o prompt do menu) é texto livre do tenant, então passa pela
MESMA proteção anti-RCE já aplicada em `data` de dialplan
(`IsSafeDialplanData`) — nunca pode virar `${system(...)}`.

Tela "Telefonia > IVR": lista de menus com as opções de cada um, criação
com nome/contexto (auto-gerado do nome, editável) + linhas dinâmicas de
opção (dígito + select de ramal já cadastrado + rótulo), 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.

Testado ponta a ponta criando um menu DE VERDADE pela tela/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. Falta pipeline de upload/TTS de áudio, sub-menus e destino
"fila" — ver docs/INBOUND_ROUTES.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 17:24:07 -03:00
83eb2c5aea fix(telefonia): tenant novo nasce sem conseguir discar entre ramais
Achado real reportado pelo usuário: "a ligacao entre ramais nao esta
funcionando". Não era regressão de nada desta sessão — o tenant onde o
usuário registrou os próprios ramais de teste 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 testes anteriores (Acme). Todo tenant novo nascia
incapaz de ligar entre os próprios ramais até alguém configurar isso à
mão em Telefonia > Dialplan — um gap real de produto.

`DEFAULT_DIALPLAN_EXTENSIONS` (novo, packages/telephony) tem as mesmas 2
regras já testadas ponta a ponta com chamada real (PHASE 53): discagem
interna com pickup de grupo + `*8`. `TenantsController.create()` agora
semeia essas regras e já ativa a versão 1 do contexto default, na mesma
transação de criação do tenant.

Reparo pontual aplicado no tenant existente do usuário (mesmas 2 regras,
via SQL direto — não existe endpoint de "reparar tenant já criado").
Testado com uma chamada real: originate de um ramal pro outro tocou de
verdade no aparelho do usuário (RINGING -> NO_ANSWER, não mais dialplan
not found). Auto-seed testado criando um tenant novo de verdade via
POST /tenants — confirmado no banco que a versão 1 já nasce ACTIVE.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 17:10:15 -03:00
7763afacc0 feat(dialplan): IVR — play_and_get_digits + branching por dígito, testado com DTMF real
Continuação de "montar o IVR e as rotas de entrada": com a fundação de
roteamento por DID já funcionando, faltava o menu em si.

`play_and_get_digits` adicionado ao allowlist de applications (só coleta
dígitos numa variable, nunca executa nada — mesmo risco zero dos outros
já permitidos). `ALLOWED_CONDITION_FIELDS` ganhou um segundo modo:
`${variavel}` simples além dos 6 campos fixos, com a mesma garantia
anti-RCE de `data` (o regex exige identificador puro, sem parênteses,
então nunca vira `${funcname(...)}`).

Achado real construindo o primeiro menu de teste: FreeSWITCH resolve
TODAS as condições de um contexto antes de executar qualquer ação — uma
variable setada por `play_and_get_digits` nunca afeta o casamento de
OUTRA extension na mesma passada. Descoberto porque o primeiro desenho
(2 extensions separadas, uma coletando o dígito, outra checando
`${ivr_choice}`) nunca bateu num teste real com DTMF — o regex era
avaliado com a variable ainda vazia. A forma que funciona: um `transfer`
explícito, na MESMA extension, usando o dígito coletado como novo
destination_number — isso dispara uma consulta de dialplan nova (as
variables já setadas persistem), e as extensions seguintes casam por
destination_number normal.

Testado ponta a ponta com DTMF de verdade: como linphonec não tem um
comando de enviar DTMF interativo, usei `fs_cli uuid_recv_dtmf` (API do
FreeSWITCH que simula o dígito chegando como input do chamador, mesmo
caminho que RFC2833 usaria). Softphone externo ligou pro DID, o IVR
atendeu, tocou o prompt, esperou o dígito: "1" bridged com um ramal real
(atendeu, áudio PCMU bidirecional confirmado); "2" foi pra uma resposta
alternativa (tom diferente) — confirma que a ramificação distingue de
verdade, não só cai sempre na primeira opção.

Detalhes completos em docs/INBOUND_ROUTES.md. Falta só a UI dedicada de
autoria de menu (hoje construído à mão no editor genérico de dialplan) —
backend já suporta tudo que ela precisaria gerar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 17:00:16 -03:00
ed4ae4421e feat(telefonia): rotas de entrada por DID — fundação real pro IVR
Pedido do usuário: "pode iniciar a montar o IVR e as rotas de entrada".
Investigando antes de escrever qualquer XML de IVR, achei que NENHUMA
chamada de entrada por tronco tinha como funcionar hoje, IVR ou não:
nenhuma carregava `b2bcall_tenant_id` (só REGISTER de ramal e discagem de
saída setam essa variable), e mesmo corrigindo isso, o profile "external"
apontava pro contexto "public" vanilla — um arquivo ESTÁTICO, que sempre
ganha de uma consulta ao mod_xml_curl, então nunca seria dinâmico
enquanto se chamasse "public".

Perguntei ao usuário a granularidade certa (por DID ou por tronco) antes
de desenhar o schema — escolheu por DID, mais flexível (um tronco pode
carregar vários números com destinos diferentes). `InboundRoute` nova
(RLS real, FORCE ROW LEVEL SECURITY): `didNumber` único GLOBAL entre
tenants (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, nunca
uma query sem contexto de RLS.

Dockerfile repontou o profile external pra context="inbound" (sem
arquivo estático, cai no mod_xml_curl de verdade). O XML gerado pra esse
contexto injeta b2bcall_tenant_id + domain_name (achado real: sem setar
domain_name explicitamente, o bridge da "Discagem interna" resolvia pro
domínio GLOBAL default, nunca pro do tenant) e transfere pro dialplan
real do tenant — reaproveita 100% do que já existe, inclusive pickup de
grupo (PHASE 53).

CRUD completo (InboundRoutesController, permissions novas no seed) + tela
"Telefonia > Rotas de Entrada" no frontend.

Testado com uma chamada REAL: um softphone registrado como ramal normal,
outro discando direto pro profile external (porta 5080, sem registrar —
exatamente como um provedor de tronco manda) um DID cadastrado. `show
channels` confirma: tenant certo, contexto certo, domínio certo no
bridge, codec PCMU negociado nos dois lados, ramal tocou e atendeu de
verdade. Detalhes completos, inclusive uma tentativa de teste que falhou
por limitação do canal `loopback` (não um bug), em docs/INBOUND_ROUTES.md.

O IVR em si (menu com play_and_get_digits) fica pra próxima fase — esta
é a fundação sem a qual nada de chamada de entrada funcionava.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 16:45:18 -03:00
fc4f5db13a feat(monitoramento): status de registro dos ramais (online/offline) em tempo real
Pedido do usuário: "antes de ir para IVR no monitoramento tem que mostrar
quantos ramais estão online e o status de cada ramal criado".

apps/api roda no host e não alcança o ESL do FreeSWITCH diretamente
(freeswitch:8021 só existe na rede interna do Docker) — mesmo padrão já
documentado em platform-freeswitch.controller.ts. Por isso, seguido o
mesmo caminho já usado por Trunk.status: apps/freeswitch-events (conexão
ESL permanente) já consumia sofia::register/unregister/expire pra outros
fins, então virou a fonte de verdade — grava `Extension.registeredAt` a
cada evento, mais uma reconciliação completa (`show registrations`) ao
conectar/reconectar no ESL pra não ficar com dado desatualizado se o
serviço caiu no meio de uma sessão de registro.

GET /extensions agora devolve `registeredAt`; a página /app/monitoramento
ganhou um painel "Ramais" com status ao vivo (online/offline, desde
quando, grupo de captura) e um instrumento "Ramais online: X de Y" — a
mesma conexão SSE que já existia só precisou aprender a atualizar esse
novo estado a partir dos eventos EXTENSION_REGISTERED/UNREGISTERED, que
já estavam sendo publicados e só apareciam no log de eventos.

Testado: reconciliação confirmada rodando no container real (2 ramais já
registrados antes do restart do fs-events foram marcados online
corretamente); GET /extensions confirmado devolvendo registeredAt (null
num ramal recém-criado, sem registro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 16:02:14 -03:00
af46739af2 fix(telefonia): pickup de grupo funcionando de verdade + RCE no dialplan + domínio real no REGISTER
Pedido explícito do usuário: "testa criando um ramal com callgroup e captura
chamada de outro ramal". O teste com softphones reais (não só leitura de
código) achou 3 problemas que a fase anterior tinha dado como resolvidos:

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 15:24:58 -03:00
56f73bdb8f fix: domínio SIP único por tenant + grupo de captura + revelar senha do ramal
Achado real, detalhado pelo usuário testando o PABX de verdade: 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})), 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. Investigado direto no container antes de mudar qualquer coisa: o sofia
profile "internal" (vanilla) já vem com <domain name="all" alias="true".../> — o
FreeSWITCH sempre aceitou domínio dinâmico por REGISTER, o bug era só a aplicação.
Nenhuma mudança de infra foi necessária.

Tenant.telephonyDomain agora é obrigatório e @unique (migration com backfill:
tenants existentes ganharam {code}.b2bcall.net, e os Extension.domain já criados
foram atualizados junto). Tela de criação de tenant sugere {code}.b2bcall.net ao
digitar o código, editável.

Extension.callGroup (novo) — ramais no mesmo grupo podem capturar a chamada um do
outro (*8), fora do grupo não. Vira a variable call-group no directory XML; a regra
de dialplan do *8 em si fica pra configurar em Telefonia > Dialplan (editor já
existe). Editável na criação e depois (PATCH /extensions/:id, novo).

POST /extensions/:id/reveal-password (novo) — achado real: "show once" puro não
funciona no dia a dia (reconfigurar um telefone/softphone precisa da senha de novo;
forçar reset toda vez derruba outro aparelho já configurado). sipPasswordEnc sempre
foi criptografia reversível, nunca hash — só não estava exposto. Auditado
(EXTENSION_PASSWORD_REVEALED) por ser sensível mesmo sem escrita.

apps/freeswitch-config reconstruído e reiniciado. Testado ponta a ponta contra o
container REAL via docker exec: senha revelada bate com a gerada na criação,
call-group aparece no XML, e o mesmo número de ramal em domínios diferentes nunca
se confunde (isolamento cross-tenant confirmado de verdade).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 12:05:25 -03:00
7bc4b2e819 fix(frontend): renovação automática de sessão — achado real reportado pelo usuário
Achado real: ACCESS_TOKEN_TTL = 15min 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. Reportado pelo
usuário: "Ctrl+F5" depois de um tempo deu "Token de acesso inválido ou expirado".

apps/frontend/src/middleware.ts (novo) decodifica o exp do access token guardado no
cookie (só leitura, sem verificar assinatura — isso continua sendo a API) e, se
faltar menos de 60s pra vencer, chama POST /auth/refresh antes da Server Component
rodar, gravando o cookie novo tanto na resposta quanto na própria requisição (padrão
documentado do Next.js — sem isso a MESMA requisição que disparou o refresh ainda
leria o cookie velho).

Rede de segurança pro caso do refresh token também ter vencido/sido revogado:
apps/frontend/src/app/error.tsx detecta uma mensagem de sessão expirada, desloga e
manda pro /login sozinho, em vez de mostrar a tela crua de erro. 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 (achado testando o primeiro corte).

Testado ponta a ponta com refresh token de verdade: token forjado pra vencer em 20s
+ refresh token real → renovação automática confirmada, página protegida carrega
normal. Com refresh token inválido de propósito → confirmado o fallback: error
boundary detecta, desloga e redireciona pro login sozinho.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 11:18:24 -03:00
a15ea26cb5 feat(frontend): branding "B2BCall by Handix" — logos novos em todo o app
Usuário forneceu 3 logos novos (B2BCall horizontal, B2BCall quadrado, Handix —
empresa mãe). Processados com sharp (trim de whitespace + resize) e salvos em
apps/frontend/public/branding/, substituindo o antigo b2blogo.png (mesmo desenho,
só com mais espaço em branco ao redor).

Achado ao processar: Logo_Handix.png original era RGB sem canal alpha (fundo branco
opaco) — aplicar o mesmo filtro brightness-0 invert já usado no logo B2BCall pro
painel escuro do login virava um retângulo branco sólido. Corrigido recolorindo
pixels próximos de branco como transparentes via sharp antes de salvar.

Sidebar (tenant e platform) ganha o ícone do B2BCall ao lado do nome do tenant/
"PLATFORM" no cabeçalho (canto superior esquerdo); topbar ganha o logo da Handix
com link pra www.handix.com.br (canto superior direito, escondido no mobile). Login
ganha "BY HANDIX" abaixo do logo principal e um rodapé com o logo da Handix + link.

Testado ponta a ponta via Puppeteer: login, dashboard tenant/platform (claro e
escuro), sidebar recolhida, drawer mobile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 11:01:55 -03:00
4b927e9d9e fix(frontend): tela de "Trocar senha" no primeiro acesso
Achado real reportado pelo usuário testando: todo usuário criado (tenant novo em
Clientes > Tenants, ou convite em Administração > Usuários) nasce com
mustChangePassword: true (agente.md 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.

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 pede a senha
temporária + nova senha (2x), chama POST /auth/change-password, e reaproveita a mesma
decisão platform/tenant do login normal (/api/post-login).

Testado ponta a ponta com um usuário de teste de verdade (convidado, nunca logado
antes): login → /trocar-senha → senha trocada → /app direto.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 10:41:20 -03:00
798aa7b579 feat: "itens sem permissão não aparecem" — filtro de menu por permission (secao 169)
getUserPermissionKeys(userId, tenantId?) (novo, packages/auth) devolve todas as
permission keys do usuário no contexto atual, mesma query de userHasPermission só
que retornando o conjunto inteiro. GET /auth/me agora inclui permissionKeys — leitura
adicional só pra UX, nunca decisão de autorização (isso continua sendo o
PermissionGuard em cada endpoint).

NavLeaf/NavSection ganham campo opcional permission; todos os ~35 itens de menu
(tenant e platform) anotados com a permission key mínima que o endpoint GET
correspondente já exige. filterNavByPermissions() (novo, nav-types.ts) esconde o
item, ou a seção inteira se nenhum filho sobrar.

TenantSidebar/PlatformSidebar/*Topbar são client components que importam
TENANT_NAV/PLATFORM_NAV direto (não recebem como prop do server) porque os ícones
(LucideIcon, funções) não são serializáveis através da fronteira RSC — restrição já
documentada desde a PHASE 23. O filtro roda dentro desses wrappers, client-side,
recebendo só permissionKeys: string[] (serializável) como prop.

Testado ponta a ponta com um supervisor de teste de verdade (convidado, senha
trocada, logado via UI): menu perde Telefonia > Dialplan e a seção Administração
inteira (nenhum permission bate). Confirmado que a autorização real continua valendo
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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 09:17:05 -03:00
6f18b3a297 feat(frontend): Relatórios > Chamadas — filtros completos
O backend (CallsController.list) já aceitava todos os filtros da especificação
(from/to/extensionId/agentId/queueId/campaignId/trunkId/phone/hangupCause/
dispositionId) desde a fase CDR — só a tela nunca expunha nada além de busca por
telefone. Adicionados os 8 filtros restantes (período real, ramal/agente/fila/
campanha/tronco/disposição como Select, causa de encerramento como texto livre),
cada um refletido na querystring, mesmo padrão de filtro-via-URL já usado em Leads/
Callbacks/Assinaturas.

Testado ponta a ponta: selecionar um filtro de fila navega pra ?queueId=<uuid> e a
busca server-side já aplica o filtro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 09:04:58 -03:00
fcf334c7c0 feat(frontend): Billing > Consumo (platform) + Sistema > Configurações — zera "em breve"
GET /billing/consumo agrega o mesmo uso bruto de /reports/consumo (tenant), só que
em loop por todos os tenants — nunca dinheiro, só quantidade (dinheiro é Billing >
Relatórios, já existia).

GET /platform/system-config: "Sistema > Configurações" nunca teve escopo definido na
especificação. Decisão desta implementação: painel somente leitura das flags de
segurança/infra que já existem como variável de ambiente (DIALER_SIMULATION/
ALLOW_REAL_OUTBOUND_CALLS, 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.

Com isto, todo item dos menus Platform e Tenant tem uma tela real por trás — zero
"em breve" restando em nav-data.ts nos dois lados.

Testado ponta a ponta: /billing/consumo batendo com os mesmos números já vistos em
Relatórios > Consumo/Quotas, /platform/system-config confirmado mostrando o valor
real do .env desta VM.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 09:01:10 -03:00
cc80310cb5 feat(frontend): Platform > IA > Providers/Modelos/Uso/Custos + achado real de autorização
Providers/Modelos reaproveitam os endpoints /ai/providers e /ai/models já
existentes (só filtram scope GLOBAL na tela); Modelos expõe custo unitário
(inputCost/outputCost/audioCost) que a tela de tenant nunca mostra, porque só
platform precisa cadastrar preço. GET /platform/ai-usage (novo) agrega uso bruto de
AIUsageRecord de todos os tenants no mês corrente (Uso) e estima custo casando cada
registro com o AIModel correspondente (Custos) — quando não dá pra casar, o registro
fica de fora da soma e o tenant é marcado costIncomplete, nunca um número inventado.

Achado real de autorização ao revisar AIModelsController antes de construir a tela
de Modelos: POST/DELETE /ai/models não checava isPlatformUser quando o alvo era
scope=GLOBAL — como a RLS híbrida (OR tenant_id IS NULL) deixa qualquer tenant
ENXERGAR um provider/modelo GLOBAL, qualquer Tenant Admin com `ai.manage` (permission
de escopo TENANT) conseguia injetar um modelo no catálogo global ou desabilitar um
modelo GLOBAL só sabendo o id. O endpoint irmão (AIProvidersController) já tinha o
check certo; corrigido com o mesmo padrão. Confirmado com um teste de ataque real:
403 depois do fix (era 201/sucesso antes), com regressão confirmando que BYOK do
próprio tenant continua funcionando normalmente.

Testado ponta a ponta: provider+modelo GLOBAL com custo real, uso de teste inserido
direto no Postgres, /platform/ai-usage devolvendo o valor exato esperado (bate com a
conta manual), tudo removido no final.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 08:53:35 -03:00
12af12f276 feat(frontend): Platform > Infraestrutura > FreeSWITCH/SIP Profiles/Nodes
GET /platform/freeswitch/channels|profiles|nodes fazem introspecção ESL real
(show channels/show calls/sofia status/show registrations/status/show gateways),
reaproveitando os métodos do FreeSwitchTelephonyProvider já verificados manualmente
contra o FreeSWITCH real. Mesmo padrão de conexão avulsa do PlatformHealthController
(connect → comando → disconnect).

Nunca deixa a indisponibilidade do ESL virar 500/503 — devolve { ok: false, error }
com 200, mesma filosofia do health check. Necessário: nesta VM apps/api roda fora do
Docker e a porta 8021 é deliberadamente não publicada no host, então as 3 telas
sempre mostram essa explicação aqui (mesmo texto já usado em Infraestrutura >
Saúde), mesmo com o endpoint 100% funcional — confirmado indiretamente pelo
b2bcall-fs-events, que fala ESL de dentro da rede Docker e está com heartbeat ativo.

"Nodes" mostra explicitamente 1 node (container único, sem clustering) em vez de
fingir uma lista.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 08:41:49 -03:00
b1a409da09 feat(frontend): Platform > Clientes > Assinaturas/Quotas + achado real no dashboard
GET /billing/subscriptions e POST /billing/plan-versions já existiam desde a fase
Billing (PHASE 22) sem tela nenhuma — Assinaturas agora deixa escolher um tenant, ver
o histórico de versões de preço assinadas, e criar uma nova assinatura reaproveitando
uma versão existente ou versionando um preço novo na mesma ação (preço nunca é
sobrescrito, sempre uma linha nova).

GET /platform/quotas (novo) agrega uso vs. limite do plano em todos os tenants de
uma vez (ramais/agentes/troncos/filas/campanhas/chamadas-mês/armazenamento), com a
tela destacando quem está em 80%+ (amarelo) ou 100%+ (vermelho) do limite.

Achado real ao revisar o dashboard "Visão Geral" antes de escrever a agregação
cross-tenant de Quotas: aiUsageThisMonth/recordingStorageBytes sempre devolviam
zero/vazio, porque a query rodava direto no Prisma sem nenhum app.current_tenant_id
setado — ai_usage_records/recordings têm FORCE RLS, então a policy nega a leitura
silenciosamente (0 linhas, sem erro), não importa quanto uso real existisse. Mesma
classe de bug já corrigida 2x antes nesta sessão; corrigido com o mesmo padrão (loop
withTenantContext por tenant). Confirmado inserindo um AIUsageRecord de teste no
Postgres, vendo o número aparecer, e removendo o teste depois.

Testado ponta a ponta: fluxo completo de criar assinatura via UI pro tenant Beta
Corp (nova versão de preço + assinatura, confirmado na tela e no banco), Quotas
mostrando os números reais dos dois tenants de teste.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 08:32:53 -03:00
de0b632fc9 feat(frontend): Relatórios > Consumo (tenant)
GET /reports/consumo agrega os 2 ledgers imutáveis de uso (UsageEvent +
AIUsageRecord — os mesmos que o RatingEngine usa pra faturar) por meter/tipo no mês
corrente: chamadas, minutos, dias ativos, armazenamento de gravação, tokens de IA.
Nunca calcula valor em dinheiro (isso é billing/RatingEngine, platform-only) —
decisão deliberada pra não duplicar essa lógica fora dele. Devolve também os limites
do plano (maxMonthlyCalls/maxRecordingStorageGb) pra comparação.

Tela /app/relatorios/consumo reaproveita o InstrumentTile do dashboard, zeros
honestos em vez de esconder seção. Testado ponta a ponta contra o tenant Acme real
(2 dias-tronco já ledgerados aparecem certos, resto zerado por não ter chamada
rodada ainda).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 08:22:34 -03:00
2fb5010283 feat: Administração > Configurações, remover usuário do tenant, Discador > Callbacks
Fecha os últimos gaps do módulo Administração (agente.md secao 169): tela de
Configurações self-service do próprio tenant (GET/PATCH /tenant-settings, nunca
aceita tenantId arbitrário — só user.tenantId das claims), e DELETE /users/:id pra
remover alguém do tenant, com duas proteções que não existiam antes (não deixa
remover a si mesmo, não deixa remover/rebaixar o último Tenant Admin).

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 08:16:38 -03:00
52e1b4f3b7 feat(frontend): Discador > Leads (tela dedicada)
/app/discador/leads — seletor de campanha, busca por telefone/nome,
adicionar lead individual, remover com confirmação de 2 cliques. O
backend (GET/POST/DELETE /campaigns/:campaignId/leads) já existia desde a
PHASE 15; a prévia de leads no detalhe da campanha já apontava pra esta
tela ("a lista completa vive em Discador > Leads") desde a PHASE 26, só
faltava construir.

LEAD_STATUS_LABELS novo (16 valores) — badge própria, não reusa
StatusBadge (mesma cautela de gênero de AgentStateBadge/TenantStatusBadge:
READY/FAILED/COMPLETED colidiriam com rótulos de campanha).

Testado ponta a ponta: campanha de teste criada, 3 leads adicionados (2
via API, 1 via UI), busca filtrando corretamente, remoção confirmada via
API. Smoke test nas 19 telas do tenant + platform, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 00:44:09 -03:00
e9dc973aee feat(frontend): Administração > Usuários e Perfis (convidar, trocar papel)
POST /users (convidar) fecha uma lacuna real: até aqui só dava pra
adicionar alguém a um tenant criando o tenant inteiro ou via script. Se o
e-mail já existe na plataforma, só adiciona membership+role (sem tocar na
senha); se não existe, cria a conta com senha gerada e revelada uma única
vez. PATCH /users/:id/role troca o papel (substitui, 1 papel por tenant).

GET /roles novo (tenant-facing) — versão de /platform/roles filtrada só
pras roles de escopo TENANT, sem expor que platform_super_admin existe.

Frontend: /app/administracao/usuarios (convidar com papel, trocar papel
de qualquer membro exceto o próprio usuário logado) e /perfis (o que cada
papel pode fazer, só leitura).

Testado ponta a ponta contra a API real: convidada conta nova com senha
revelada, convidado usuário com papel Agente, trocado pra Supervisor via
PATCH, confirmado persistido. Perfis mostra as 3 roles de tenant certas.
Smoke test nas 19 telas do tenant + 10 telas platform, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 00:22:18 -03:00
03ec0d556b feat(platform): Sistema > Permissões (catálogo RBAC, só leitura)
GET /platform/roles novo — lista as 4 roles do sistema com as permissions
de cada uma, mais o catálogo completo de permissions. roles/permissions
não têm RLS (catálogo global do seed); só leitura, RBAC é system-defined,
sem UI de criar role customizada ainda.

Frontend: /platform/sistema/permissoes, um card por role + tabela do
catálogo completo. Testado ponta a ponta: as 4 roles reais (platform_super_admin
33 permissions, tenant_admin 30, supervisor 17, agent 2) corretas. Smoke
test nas 19 telas do tenant + 9 telas platform, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 20:41:19 -03:00
c95c6805fb feat(platform): Billing > Fechamentos e Relatórios
GET /billing/periods e /billing/statements só serviam o próprio tenant do
JWT — sem uso pra um platform admin escolhendo um tenant arbitrário.
Adicionado GET .../by-tenant/:tenantId nos dois (mesmo padrão já usado em
Subscriptions), e GET /billing/statements/:id ganhou um ?tenantId=
opcional só aceito de quem tem role de plataforma.

Frontend: /platform/billing/fechamentos (fecha/reabre período por
tenant, seletor via querystring pra não duplicar rota) e /relatorios
(statements por tenant, detalhe com itens por categoria).

Bug real achado testando o fluxo: fechar um período de 01/08 a 31/08
mostrava "31 de jul." a "30 de ago." — meia-noite UTC de uma data-only
vira o dia anterior no timezone local do servidor. Corrigido com
formatDateUTC novo, usado só em fronteiras de calendário (não em
timestamps de verdade, que continuam com formatDate local).

Testado ponta a ponta contra a API real: período fechado, statement
gerado (R$ 0,00 honesto — Acme sem assinatura/price book ainda), detalhe
correto. Smoke test nas 19 telas do tenant + 8 telas platform, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 20:31:12 -03:00
a23e68b011 feat(platform): Sistema > Usuários/Auditoria, Infraestrutura > Saúde
Três endpoints novos, todos platform-only: GET /platform/users (cross-
tenant, users não tem RLS) + PATCH .../status (desabilitar tem efeito
real — login() já checava status ACTIVE desde a PHASE 04); GET
/platform/audit-log (últimos 200 eventos, audit_logs também sem RLS,
linha imutável); GET /platform/health (Postgres/Redis + FreeSWITCH via
conexão ESL avulsa, sem manter estado).

Achado de arquitetura documentado explicitamente na própria tela: o check
de FreeSWITCH sempre falha neste ambiente porque apps/api roda no host e
a porta 8021 é deliberadamente não publicada (decisão da PHASE 01/05) —
não é um bug, é a rede isolada do jeito certo.

Frontend: /platform/sistema/usuarios, /auditoria, /platform/
infraestrutura/saude. Testado ponta a ponta contra dados reais (3
usuários da plataforma, audit log com eventos reais desta sessão,
inclusive uma referência órfã tratada corretamente). Smoke test nas 19
telas anteriores, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 20:04:05 -03:00
2c1269a83a feat(platform): Clientes > Tenants e Planos — CRUD real (backend novo)
Não existia NENHUM endpoint pra criar/listar/editar tenant nem plano até
aqui — só via script/seed ad hoc. Dois controllers novos: TenantsController
(/tenants) e PlansController (/plans), platform-only.

POST /tenants cria o tenant E o primeiro usuário (Tenant Admin) numa
transação só — sem esse usuário o tenant fica inacessível. Senha gerada e
devolvida em texto puro só na resposta de criação (revela uma vez, mesmo
padrão de Ramais/SIP).

Bug real achado testando o próprio endpoint: GET /tenants calculava
memberCount sem contexto de RLS — tenant_memberships tem FORCE RLS, então
nem platform admin enxerga linha nenhuma sem app.current_tenant_id
setado, o campo sempre voltava 0. Corrigido abrindo o contexto de cada
tenant um de cada vez.

Frontend: /platform/clientes/tenants (lista+busca), /tenants/new (cria
tenant+admin, revela senha), /tenants/:id (troca status/plano, preview
dos limites ao vivo), /platform/clientes/planos (CRUD completo dos
limites). Testado ponta a ponta: plano novo -> tenant novo com admin real
-> troca de plano persistida, confirmada via API. Smoke test nas 19 telas
anteriores, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 19:50:12 -03:00
3479dbd007 feat(frontend): Monitoramento em tempo real (WebSocket via proxy SSE)
O RealtimeGateway (backend, desde a PHASE 13) autentica a conexão
socket.io via auth.token no handshake — um JWT bruto que um
EventSource/WebSocket do browser não tem como mandar sem passar por JS
legível no client, quebrando o princípio seguido em todo o resto do
frontend (token só existe no cookie httpOnly). Resolvido com um proxy:
apps/frontend/src/app/api/monitoring/stream/route.ts roda no servidor,
conecta no socket.io real com o access token do lado do servidor, e
reencaminha cada evento pro browser como Server-Sent Events — o
EventSource do client só precisa do cookie de sessão, nunca do token.

/app/monitoramento: badge de conexão, contadores de sessão (chamadas
criadas/atendidas/encerradas), filas ao vivo (QUEUE_MEMBER_COUNT), estado
de agentes ao vivo (AGENT_STATE_CHANGED, semeado do GET /agents inicial),
feed dos últimos 50 eventos. Menu "Monitoramento" vira 1 link direto em
vez de 5 sub-itens placeholder — o painel novo já cobre tudo numa página
só.

Testado ponta a ponta contra o pipeline real (não simulado): cliente
socket.io cru confirmou a API entregando o evento, curl -N confirmou o
proxy reencaminhando, e com a página aberta de verdade num browser
(Puppeteer) disparei POST /agents/me/login e /logout por fora — o badge
do agente mudou ao vivo e os eventos apareceram no feed sem recarregar a
página. Smoke test de regressão nas 19 telas anteriores, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 19:17:39 -03:00
7f56c96454 fix(agents): POST /agents nunca verificava tenant de userId/extensionId
Achado numa revisão de segurança sobre o trabalho da PHASE 29: um FK do
Postgres só checa que a linha referenciada 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 no create do Agent (violação do princípio já
seguido em todo o resto do código: 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, não autorização.

Corrigido com uma checagem explícita de TenantMembership/Extension antes
do create (400 se não pertencer). Reverificado ponta a ponta: criação
legítima continua funcionando igual.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 16:37:32 -03:00
75212cafe6 feat(frontend): IA > Scorecards, Prompts, Configurações
Três telas novas fecham quase todo o menu IA: Scorecards (critérios
ponderados, array dinâmico como no Dialplan), Prompts (templates com
versionamento — editar = criar versão + ativar numa ação só, já que não
existe endpoint pra listar histórico), e Configurações (providers BYOK +
modelos). Chave de API nunca é reexibida em texto puro, nem uma vez — só
preview mascarado, mesmo na resposta de criação.

Novo componente Textarea em components/ui/input.tsx (mesmo estilo de
Input/Select) — primeira tela que precisa de texto longo.

Testado ponta a ponta contra a API real: scorecard, template de prompt
(v1 -> editar -> v2 ativada), provider BYOK + modelo referenciando ele.
Smoke test de regressão nas 16 telas anteriores do tenant + platform,
todas 200. Typecheck limpo no monorepo inteiro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 16:30:33 -03:00
4d112fcea0 feat(frontend): Relatórios > Chamadas + Gravações (proxy de áudio autenticado)
Relatórios > Chamadas: lista das últimas 500 chamadas dos últimos 30 dias,
nomes de fila/agente/campanha/disposição resolvidos client-side, busca por
telefone. Só o filtro de telefone nesta primeira versão.

Gravações: lista + player + download. Achado de arquitetura resolvido
antes de codar: <audio src>/<a download> não mandam Authorization Bearer
(só cookie), e a API nunca expõe o storage por URL direta — criado um
proxy autenticado (Route Handler /api/recordings/[id]/audio) que lê o
cookie de sessão, chama a API real com o access token do lado do servidor,
e reencaminha o stream com Content-Disposition: inline (a API manda
attachment). Mesmo princípio de apiFetch: token nunca chega em JS legível.

Testado ponta a ponta contra a API real (estados vazios honestos, proxy
confirmado 401 sem sessão). Smoke test de regressão nas 15 telas
anteriores do tenant + platform, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 16:20:01 -03:00
f1cadddc91 feat: Call Center > Agentes + Telefonia > Dialplan (novo endpoint GET /users)
Fecha a lacuna de backend que travava Agentes: POST /agents exigia um
userId de um usuário já existente do tenant, mas não havia nenhum endpoint
pra listar usuários. Novo GET /users (gated por users.manage, não
agents.manage — listar identidade de login é administração de usuário)
via TenantMembership, mesmo padrão de RLS já usado em listUserTenants.

Duas telas novas: Call Center > Agentes (criar/listar/remover, seletor de
usuário só mostra quem ainda não é agente) e Telefonia > Dialplan (editor
estruturado do contexto default — regras com condição/ações dinâmicas,
painel de Versões com gerar/ativar, reativar versão antiga = rollback).

Testado ponta a ponta contra a API real do tenant Acme, incluindo o ciclo
completo de dialplan (criar regra -> gerar v1 -> ativar -> badge "Ativa").
Smoke test de regressão nas 13 telas anteriores do tenant + platform.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 16:10:45 -03:00
ca49504f0a feat(frontend): Relatórios (Filas/Agentes/Campanhas/IA) + PHASE 28 security review
Quatro páginas novas em /app/relatorios/*, uma rota por relatório (não abas de
uma página só, pra não quebrar o realce de "ativo" da sidebar quando vários
itens de menu apontam pro mesmo relatório) — reusa os endpoints de reports
que já existiam desde as fases de CDR/AI, nenhum backend novo.

Security quality gate (agente.md secao 224): revisão dedicada sobre todo o
diff desde origin/main (billing + frontend inteiro, 7 commits) não achou
nenhuma vulnerabilidade de alta confiança. Suítes de teste de isolamento
multi-tenant, autenticação/RBAC e rating engine reexecutadas do zero e
verdes. TODO.md documenta o escopo real ainda faltando (Agentes bloqueado
por falta de endpoint de listagem de usuários, Dialplan, Monitoramento em
tempo real, Gravações, IA CRUD, Relatórios > Chamadas/Consumo) em vez de
alegar a aplicação "finalizada".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 14:34:24 -03:00
de1e7ac49c feat(frontend): Call Center > Filas/Pausas/Disposições, Telefonia > Troncos, Discador > Lista de Bloqueio
Cinco telas novas no app do tenant, todas contra endpoints de backend que já
existiam (Queues/PauseReasons/Dispositions/Trunks/Suppression) — mesmo padrão
de listagem+form inline+remoção com confirmação de 2 cliques usado em Ramais.
StatusBadge ganhou o mapa de TrunkStatus. Corrigido bug real: PauseReasonsController.list()
não filtrava enabled:true, então um motivo removido nunca sumia da lista.
Testado ponta a ponta contra a API real do tenant Acme.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-29 11:50:09 -03:00
927a2623c2 feat(frontend): Discador > Campanhas — wizard de 7 passos, ciclo de vida, leads
Tela completa de campanhas do discador preditivo: listagem com busca/
filtro/status, wizard de criacao em 7 passos exatamente como a
especificacao nomeia (Geral -> Telefonia -> Discagem -> Horarios ->
Gravacao e IA -> Leads -> Revisao, agente.md secao 170) — nada e criado
ate confirmar na revisao, upload de CSV de leads na mesma acao de
criacao — e detalhe com acoes de ciclo de vida (iniciar/pausar/drenar/
parar, cada botao so aparece quando a transicao e valida pro status
atual), pacing ao vivo, previa de leads com import adicional, e remover.

Corrige de quebra um erro real de build: Server Actions exportadas como
arrow function que so repassam argumentos pra outra funcao quebram
("Server Actions must be async functions") — precisam ser declaradas
como async function de verdade.

Testado ponta a ponta com fila+tronco reais: os 7 passos preenchidos e
revisados, campanha criada com CSV de 3 leads importado, ciclo de vida
completo start->pause->drain->stop->delete via API, e confirmado que uma
campanha iniciada de verdade e pega pelo PredictiveDialerEngine real
rodando em Docker (stats deixam de ser null depois de alguns segundos).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
2026-08-29 10:55:08 -03:00
34ef408f00 feat(frontend): Telefonia > Ramais — CRUD, reset de senha, isolamento por tenant
Tela /app/telefonia/ramais completa: listagem com busca/ordenacao,
criacao com reveal de senha SIP (gerada pela API, mostrada uma unica vez),
detalhe com redefinir senha (novo POST /extensions/:id/reset-password —
unica forma de "editar" a senha, nunca reexpoe a existente) e remover
(soft delete), confirmacao inline de 2 cliques em vez de modal. Cada tela
deixa explicito de qual tenant os ramais sao, alem do isolamento por RLS
que ja existia.

Corrige de quebra um bug real que afeta qualquer acao futura sem payload:
apiFetch sempre mandava Content-Type: application/json mesmo em requests
sem body, e o parser do Fastify rejeita body vazio com esse header.

Testado ponta a ponta com um tenant real (Acme Call Center): criar ramal,
revelar senha, redefinir (senha nova confirmada diferente da original),
remover, lista voltando vazia. Dark mode e mobile conferidos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
2026-08-29 10:17:06 -03:00