Compare commits

...

48 Commits

Author SHA1 Message Date
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
2cd638d26f feat(freeswitch): publica SIP/RTP no host pra registro de ramal de verdade
Usuário tentou registrar um ramal e não conseguiu — nenhuma porta SIP/RTP
estava publicada no host, só o Event Socket (interno). O teste ponta a
ponta da fase anterior só funcionava porque os softphones de teste
rodavam dentro da mesma rede Docker.

RTP restrito a um range fixo de 200 portas (16384-16584, via sed em
switch.conf.xml) — o default vanilla (~16k portas) é inviável de publicar
uma a uma. docker-compose.yml publica 5060/udp+tcp (SIP) e
16384-16584/udp (RTP); Event Socket continua nunca publicado.

Verificado: iptables -t nat -L DOCKER confirma DNAT correto pras 201
portas; sofia status confirma que o STUN já configurado (external_rtp_ip/
external_sip_ip) resolve pro IP público real da VM, então o SDP vai
anunciar o IP certo — não só o registro, o áudio também deve funcionar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 15:37:49 -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
4480e9265e feat(infra): apps/api e apps/frontend como services systemd
Fecha um risco documentado desde a PHASE 01: os dois processos de host
(fora do Docker) não sobreviviam a um reboot da VM, precisando ser
religados manualmente toda vez. Agora são b2bcall-api.service e
b2bcall-frontend.service (infrastructure/systemd/), enabled, sobrevivem a
reboot como os containers Docker já sobreviviam via restart:
unless-stopped.

Achado real resolvido antes de instalar: sem porta fixa, os dois
disputavam a 3000 por padrão — quem perdesse crashava com EADDRINUSE em
vez de cair pra 3001. Fixado API_PORT=3000 (.env) e PORT=3001
(Environment= direto na unit do frontend — confirmado que colocar em
apps/frontend/.env.local não funciona, o CLI do Next.js decide a porta
antes de aplicar esse arquivo). A ordem de start deixa de importar.

Rodam em modo dev (pnpm dev), não build de produção — decisão deliberada
documentada no README, virar produção é escopo maior.

Testado ponta a ponta: units instaladas e habilitadas, subiram nas portas
certas sem fallback nem crash, journalctl com logs limpos, smoke test de
regressão nas 19 telas do tenant + platform via os processos
supervisionados, todas 200. Achado incidental: as rotas do Next.js dev
levam 10-15s pra compilar na primeira visita — explica os TimeoutError
intermitentes do Puppeteer vistos ao longo da sessão, não é um bug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 00:33:44 -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
565110eac3 feat(frontend): app do tenant, dashboard ao vivo, fluxo de login multi-tenant
Corrige um gap real: o login sempre mandava pra /platform mesmo pra
usuarios sem role de plataforma, sem nunca chamar /auth/tenants ou
select-tenant. Agora POST /api/post-login decide o destino server-side
(plataforma / tenant unico / seletor com >1 tenant) antes de redirecionar,
trocando o access token quando necessario sem nunca expor token ao client.

Adiciona GET /reports/dashboard (secao 162) e a tela /app correspondente
— chamadas/agentes/TME/TMA/rates ao vivo, "consumo do plano" e "valor
estimado" seguindo a mesma disciplina de honestidade (null > numero
inventado) do dashboard de plataforma.

Shell (sidebar/topbar/drawer mobile) extraido pra components/shell,
compartilhado entre os menus Platform e Tenant (secao 168-169) via
wrappers client-only por area — corrige de quebra um erro real de
serializacao RSC (passar NavSection[] com icones como prop de Server
Component pra Client Component quebra: "Functions cannot be passed
directly to Client Components").

Testado ponta a ponta com um tenant semeado (Acme Call Center): login →
/app direto, platform admin barrado de /app e vice-versa, dashboard com
numeros reais (zeros honestos), drawer mobile, dark mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
2026-08-29 09:30:16 -03:00
aad16d136e docs(todo): registra PHASE 23 (frontend: shell, login, dashboard, tarifas)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
2026-08-29 00:55:18 -03:00
673553975e feat(frontend): app shell + login + platform dashboard + billing tarifas
Primeiro commit do frontend Next.js (agente.md secao 161-176): login
split-brand, dashboard "Visão Geral da Plataforma" com instrumentos ao
vivo, e a tela Billing > Tarifas (price books + rate decks) completa —
listagem com busca/ordenacao, criacao com itens/entradas dinamicos via
Server Actions, detalhe — ponta a ponta contra a API real de billing
(fase 22).

Corrige de quebra 2 bugs reais achados construindo Tarifas: a topbar
tinha o titulo fixo "Visao Geral" em toda pagina, e a sidebar fixa de
256px nao tinha nenhuma versao mobile (conteudo espremido em ~130px) —
agora vira drawer via Radix Dialog abaixo de lg, com titulo/descricao da
topbar resolvidos dinamicamente por rota.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
2026-08-29 00:54:29 -03:00
b27cfaab02 feat(billing): rating engine, fechamento de periodo, dashboard platform (fase 22)
Fecha a orquestracao de Billing (agente.md secao 120-139) sobre o schema/
RatingEngine puro ja existentes: escritores do ledger UsageEvent
(CALL_SECONDS no CDR, ACTIVE_DAY via sweep diario), closeBillingPeriod/
reopenBillingPeriod (fechamento imutavel com audit trail), e os
controllers de price books/rate decks/plan versions/subscriptions/
periods/statements. Corrige 2 bugs reais de RLS achados no teste ponta a
ponta (reopen sem tenant context, subscriptions sem withTenantContext) e
adiciona teste unitario do RatingEngine (17 casos).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
2026-08-29 00:28:35 -03:00
473 changed files with 29076 additions and 88 deletions

View File

@@ -1,8 +1,82 @@
# Copie pra `.env` (gitignored) e preencha. Ver docs/QA_SETUP.md pro passo a
# passo completo de como subir o ambiente do zero numa máquina nova.
# Token de acesso ao repositório de pacotes .deb do FreeSWITCH
# (freeswitch.signalwire.com) — só existe durante o build da imagem Docker
# (BuildKit secret, nunca vira camada da imagem nem chega no container em
# runtime). Peça este valor a quem já tem uma conta SignalWire; não é gerável
# localmente.
FREESWITCH_PAT= FREESWITCH_PAT=
DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public
POSTGRES_APP_USER= # apps/api roda direto no host (fora do Docker) — fixado aqui pra nunca
# depender de quem inicia primeiro disputando a porta padrão 3000 com o
# apps/frontend.
API_PORT=3000
# --- Postgres (container b2bcall-postgres, gerar senhas com `openssl rand -base64 24`) ---
POSTGRES_USER=b2bcall
POSTGRES_PASSWORD=
POSTGRES_DB=b2bcall
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
# --- Redis (container b2bcall-redis) ---
REDIS_PASSWORD=
REDIS_HOST=redis
REDIS_PORT=6379
# --- Segredos da aplicação (gerar cada um com `openssl rand -hex 32`) ---
JWT_SECRET=
JWT_REFRESH_SECRET=
ENCRYPTION_KEY=
# Event Socket do FreeSWITCH — só apps/api (systemd, no host, fora do Docker)
# lê isto. "freeswitch" é um nome DNS que só existe na rede interna do
# Docker; os serviços que RODAM dentro do Docker (fs-events, fs-config,
# predictive-dialer) têm ESL_HOST=freeswitch fixo no próprio
# docker-compose.yml, nunca leem esta variável. Precisa ser 127.0.0.1 (a
# porta 8021 é publicada só em loopback, ver docker-compose.yml) — nunca
# "freeswitch" aqui.
ESL_HOST=127.0.0.1
ESL_PORT=8021
ESL_PASSWORD=
# Segurança de chamada real (agente.md secao 186) — as DUAS precisam estar
# explicitamente na posição de risco pra originar uma chamada PSTN de
# verdade. Deixe como está pra um ambiente de QA/laboratório.
DIALER_SIMULATION=true
ALLOW_REAL_OUTBOUND_CALLS=false
# --- Gravações (agente.md secao 90-93) ---
STORAGE_PROVIDER=LOCAL
LOCAL_STORAGE_ROOT=/opt/b2bcall/data/object-storage-local
# --- Prompts de áudio do IVR (PHASE 59) ---
IVR_PROMPTS_HOST_ROOT=/opt/b2bcall/data/ivr-prompts
# --- S3/MinIO (só usado se STORAGE_PROVIDER=s3; deixe em branco pro padrão LOCAL) ---
S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
S3_FORCE_PATH_STYLE=true
# --- Connection strings derivadas dos valores acima (preencher com os
# mesmos POSTGRES_PASSWORD/REDIS_PASSWORD já definidos) ---
DATABASE_URL=postgresql://b2bcall:@localhost:5432/b2bcall?schema=public
REDIS_URL=redis://:@localhost:6379
# Role restrito (não-superuser) que a aplicação usa em runtime pra RLS
# funcionar de verdade (agente.md secao 32) — a role em si é criada por uma
# migration do Prisma, mas a senha nunca fica num arquivo de migration
# commitado (ver scripts/db-setup-app-role.sh).
POSTGRES_APP_USER=b2bcall_app
POSTGRES_APP_PASSWORD= POSTGRES_APP_PASSWORD=
APP_DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public APP_DATABASE_URL=postgresql://b2bcall_app:@localhost:5432/b2bcall?schema=public
REDIS_URL=redis://:password@localhost:6379
# Credenciais que b2bcall-fs-config usa pra autenticar no Event Socket
# quando o PRÓPRIO FreeSWITCH chama de volta (mod_xml_curl) — role de app
# dedicado, nada a ver com FS_CONFIG_USER de um tenant.
FS_CONFIG_USER= FS_CONFIG_USER=
FS_CONFIG_PASSWORD= FS_CONFIG_PASSWORD=

5
.gitignore vendored
View File

@@ -3,6 +3,7 @@
.env.* .env.*
!.env.example !.env.example
FIRST_LOGIN.txt FIRST_LOGIN.txt
ACESSO_TESTE.md
*.pem *.pem
*.key *.key
@@ -15,6 +16,10 @@ dist/
build/ build/
.next/ .next/
.turbo/ .turbo/
*.tsbuildinfo
# Impeccable per-developer overrides (see .claude/skills/impeccable)
.impeccable/config.local.json
# Logs # Logs
*.log *.log

1844
TODO.md

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@b2bcall/ai": "workspace:*", "@b2bcall/ai": "workspace:*",
"@b2bcall/auth": "workspace:*", "@b2bcall/auth": "workspace:*",
"@b2bcall/billing": "workspace:*",
"@b2bcall/database": "workspace:*", "@b2bcall/database": "workspace:*",
"@b2bcall/entitlements": "workspace:*", "@b2bcall/entitlements": "workspace:*",
"@b2bcall/shared": "workspace:*", "@b2bcall/shared": "workspace:*",
@@ -18,6 +19,7 @@
"@b2bcall/telephony": "workspace:*", "@b2bcall/telephony": "workspace:*",
"@fastify/cors": "11.3.0", "@fastify/cors": "11.3.0",
"@fastify/helmet": "13.1.1", "@fastify/helmet": "13.1.1",
"@fastify/multipart": "^10.1.1",
"@fastify/rate-limit": "11.2.0", "@fastify/rate-limit": "11.2.0",
"@nestjs/common": "^12.0.1", "@nestjs/common": "^12.0.1",
"@nestjs/core": "^12.0.1", "@nestjs/core": "^12.0.1",

View File

@@ -3,17 +3,20 @@ import {
Body, Body,
Controller, Controller,
ForbiddenException, ForbiddenException,
Get,
NotFoundException, NotFoundException,
Post, Post,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database"; import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { decryptSecret } from "@b2bcall/shared";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import { PauseDto } from "./dto/pause.dto"; import { PauseDto } from "./dto/pause.dto";
import { notifyAgentChanged, notifyTierChanged } from "./agent-sync.helper"; import { notifyAgentChanged, notifyTierChanged } from "./agent-sync.helper";
import { publishAgentStateChanged } from "../realtime/realtime-publish.helper"; import { publishAgentStateChanged } from "../realtime/realtime-publish.helper";
import { WEBRTC_PROXY_SETTING_KEY } from "../platform/platform-webrtc-proxy.controller";
async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) { async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userId: string) {
const agent = await tx.agent.findFirst({ const agent = await tx.agent.findFirst({
@@ -35,6 +38,87 @@ async function findMyAgent(tx: Prisma.TransactionClient, tenantId: string, userI
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller("agents/me") @Controller("agents/me")
export class AgentsMeController { export class AgentsMeController {
/**
* Achado real reportado pelo usuário: "não achei como deixar o agente
* online" — os endpoints de login/pausa/logout sempre existiram, mas
* não tinha nenhum jeito do frontend saber SE o usuário logado tem um
* Agent vinculado (pra mostrar o controle) nem qual o estado atual.
* 404 aqui = usuário sem Agent neste tenant, não um erro — é assim que
* o widget da topbar decide se aparece ou não.
*/
@Get()
async me(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const agent = await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
return {
id: agent.id,
name: agent.name,
state: agent.state,
enabled: agent.enabled,
hasExtension: agent.extensionId != null,
};
}
/**
* Credenciais SIP + endereço do proxy WebRTC pro softphone embutido
* (Handphone, PHASE 66) — sempre o ramal do PRÓPRIO agente, nunca um
* agentId/extensionId arbitrário do client (mesmo principio de
* `findMyAgent`). Diferente de `POST /extensions/:id/reveal-password`
* (que exige `extensions.manage`, permissão que um agente comum nunca
* tem): aqui não há permissão nenhuma além de "sou um agente logado
* com ramal vinculado" — é o próprio agente pegando a própria senha
* pra usar no softphone, não uma ação administrativa sobre o ramal de
* outra pessoa. Cada acesso fica no audit log (mesma lógica de
* `revealPassword`: decifrar de novo é sensível mesmo sem trocar nada).
*/
@Get("softphone-config")
async softphoneConfig(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const agent = await withTenantContext(prisma, tenantId, (tx) =>
tx.agent.findFirst({ where: { tenantId, userId: user.sub, deletedAt: null }, include: { extension: true } }),
);
if (!agent) {
throw new NotFoundException("Nenhum agente vinculado a este usuario neste tenant");
}
if (!agent.extension) {
return { hasExtension: false as const };
}
const setting = await prisma.platformSetting.findUnique({ where: { key: WEBRTC_PROXY_SETTING_KEY } });
await recordAuditEvent(prisma, {
action: "AGENT_SOFTPHONE_CONFIG_ACCESSED",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: agent.extension.id,
});
return {
hasExtension: true as const,
username: agent.extension.number,
domain: agent.extension.domain,
password: decryptSecret(agent.extension.sipPasswordEnc),
displayName: agent.name,
proxyUrl: setting?.value ?? null,
};
}
/** Motivos de pausa pro próprio agente escolher — sem exigir
* `agents.view` (que listaria TODOS os agentes do tenant, permissão
* que o role "agent" nunca precisou ter até aqui). */
@Get("pause-reasons")
async pauseReasons(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
await withTenantContext(prisma, tenantId, (tx) => findMyAgent(tx, tenantId, user.sub));
return withTenantContext(prisma, tenantId, (tx) =>
tx.pauseReason.findMany({ where: { tenantId, enabled: true }, orderBy: { name: "asc" } }),
);
}
/** Fluxo de login (agente.md secao 47): valida usuário (JWT) e ramal, /** Fluxo de login (agente.md secao 47): valida usuário (JWT) e ramal,
* cria sessão, configura contact/tiers no FreeSWITCH, fica AVAILABLE. */ * cria sessão, configura contact/tiers no FreeSWITCH, fica AVAILABLE. */
@Post("login") @Post("login")

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Delete, Delete,
@@ -36,6 +37,26 @@ export class AgentsController {
); );
await assertQuota(tenantId, "maxAgents", activeCount); await assertQuota(tenantId, "maxAgents", activeCount);
// Bug real, achado construindo a tela de Agentes (PHASE 29): nada
// aqui verificava que `dto.userId`/`dto.extensionId` pertencem a este
// tenant antes do create — um FK constraint no Postgres não passa
// pela RLS de visibilidade (só checa existência da linha), então um
// id de outro tenant seria aceito silenciosamente. O frontend já só
// oferece opções do próprio tenant, mas o backend é a autoridade
// (secao 31/146), nunca confia só no que o client filtrou.
const [membership, extension] = await withTenantContext(prisma, tenantId, (tx) =>
Promise.all([
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: dto.userId } } }),
dto.extensionId ? tx.extension.findFirst({ where: { id: dto.extensionId, tenantId } }) : Promise.resolve(null),
]),
);
if (!membership) {
throw new BadRequestException("userId nao pertence a este tenant");
}
if (dto.extensionId && !extension) {
throw new BadRequestException("extensionId nao pertence a este tenant");
}
const agent = await withTenantContext(prisma, tenantId, (tx) => const agent = await withTenantContext(prisma, tenantId, (tx) =>
tx.agent.create({ tx.agent.create({
data: { data: {

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common"; import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database"; import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard"; import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator"; import { RequirePermission } from "../common/decorators/require-permission.decorator";
@@ -23,6 +23,16 @@ export class AIModelsController {
); );
if (!provider) throw new NotFoundException("Provider nao encontrado"); if (!provider) throw new NotFoundException("Provider nao encontrado");
// Achado real (mesma classe já corrigida em `AIProvidersController`):
// a RLS híbrida (secao 100, `OR tenant_id IS NULL`) deixa qualquer
// tenant ENXERGAR um provider GLOBAL, mas escrever um modelo nele é
// catálogo de plataforma — sem este check, qualquer Tenant Admin com
// `ai.manage` (permission de escopo TENANT) conseguia injetar um
// modelo no catálogo visível por TODOS os tenants.
if (provider.scope === "GLOBAL" && !(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode adicionar modelo a um provider GLOBAL");
}
const model = await withTenantContext(prisma, tenantId, (tx) => const model = await withTenantContext(prisma, tenantId, (tx) =>
tx.aIModel.create({ tx.aIModel.create({
data: { data: {
@@ -67,14 +77,26 @@ export class AIModelsController {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
const tenantId = user.tenantId!; const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) => const model = await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.findFirst({ where: { id } }));
tx.aIModel.updateMany({ where: { id }, data: { enabled: false } }), if (!model) throw new NotFoundException();
);
if (result.count === 0) throw new NotFoundException(); // Mesmo achado do create(): sem este check, qualquer Tenant Admin com
// `ai.manage` conseguia desabilitar um modelo GLOBAL (visível e
// usado por todos os tenants) só por conhecer o id.
if (model.tenantId == null && !(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode remover um modelo GLOBAL");
}
if (model.tenantId != null && model.tenantId !== tenantId) {
// Nunca deveria acontecer (RLS ja' filtra), mas nunca custa checar
// explicitamente antes de uma escrita (secao 31/146).
throw new NotFoundException();
}
await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.update({ where: { id }, data: { enabled: false } }));
await recordAuditEvent(prisma, { await recordAuditEvent(prisma, {
action: "AI_MODEL_DELETE", action: "AI_MODEL_DELETE",
tenantId, tenantId: model.tenantId,
userId: user.sub, userId: user.sub,
entityType: "ai_model", entityType: "ai_model",
entityId: id, entityId: id,

View File

@@ -3,9 +3,12 @@ import { HealthModule } from "./health/health.module";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { ExtensionsModule } from "./extensions/extensions.module"; import { ExtensionsModule } from "./extensions/extensions.module";
import { TrunksModule } from "./trunks/trunks.module"; import { TrunksModule } from "./trunks/trunks.module";
import { InboundRoutesModule } from "./inbound-routes/inbound-routes.module";
import { IvrModule } from "./ivr/ivr.module";
import { DialplanModule } from "./dialplan/dialplan.module"; import { DialplanModule } from "./dialplan/dialplan.module";
import { QueuesModule } from "./queues/queues.module"; import { QueuesModule } from "./queues/queues.module";
import { AgentsModule } from "./agents/agents.module"; import { AgentsModule } from "./agents/agents.module";
import { UsersModule } from "./users/users.module";
import { PauseReasonsModule } from "./pause-reasons/pause-reasons.module"; import { PauseReasonsModule } from "./pause-reasons/pause-reasons.module";
import { RealtimeModule } from "./realtime/realtime.module"; import { RealtimeModule } from "./realtime/realtime.module";
import { CampaignsModule } from "./campaigns/campaigns.module"; import { CampaignsModule } from "./campaigns/campaigns.module";
@@ -17,6 +20,10 @@ import { ReportsModule } from "./reports/reports.module";
import { RecordingsModule } from "./recordings/recordings.module"; import { RecordingsModule } from "./recordings/recordings.module";
import { AIModule } from "./ai/ai.module"; import { AIModule } from "./ai/ai.module";
import { QualityModule } from "./quality/quality.module"; import { QualityModule } from "./quality/quality.module";
import { PlatformModule } from "./platform/platform.module";
import { BillingModule } from "./billing/billing.module";
import { TenantsModule } from "./tenants/tenants.module";
import { PlansModule } from "./plans/plans.module";
@Module({ @Module({
imports: [ imports: [
@@ -24,9 +31,12 @@ import { QualityModule } from "./quality/quality.module";
AuthModule, AuthModule,
ExtensionsModule, ExtensionsModule,
TrunksModule, TrunksModule,
InboundRoutesModule,
IvrModule,
DialplanModule, DialplanModule,
QueuesModule, QueuesModule,
AgentsModule, AgentsModule,
UsersModule,
PauseReasonsModule, PauseReasonsModule,
RealtimeModule, RealtimeModule,
CampaignsModule, CampaignsModule,
@@ -38,6 +48,10 @@ import { QualityModule } from "./quality/quality.module";
RecordingsModule, RecordingsModule,
AIModule, AIModule,
QualityModule, QualityModule,
PlatformModule,
BillingModule,
TenantsModule,
PlansModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,7 +1,10 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, UseGuards } from "@nestjs/common"; import { Body, Controller, Get, HttpCode, HttpStatus, NotFoundException, Post, Req, UseGuards } from "@nestjs/common";
import type { FastifyRequest } from "fastify"; import type { FastifyRequest } from "fastify";
import { getPrismaClient } from "@b2bcall/database";
import { import {
changePassword, changePassword,
getUserPermissionKeys,
isPlatformUser,
listUserTenants, listUserTenants,
login, login,
logout, logout,
@@ -44,6 +47,37 @@ export class AuthController {
await logout(user.sessionId); await logout(user.sessionId);
} }
@UseGuards(JwtAuthGuard)
@Get("me")
async me(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const dbUser = await prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, email: true, name: true },
});
if (!dbUser) throw new NotFoundException();
// Nome do tenant ativo pra topbar (frontend, PHASE 23) — `tenants` não
// tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md), leitura direta
// sem `withTenantContext` é segura aqui.
const tenant = user.tenantId
? await prisma.tenant.findUnique({
where: { id: user.tenantId },
select: { id: true, code: true, tradeName: true, legalName: true },
})
: null;
return {
...dbUser,
isPlatformUser: await isPlatformUser(user.sub),
tenant: tenant ? { id: tenant.id, code: tenant.code, name: tenant.tradeName ?? tenant.legalName } : null,
// "Itens sem permissão não aparecem" (secao 169) — só UX de menu, o
// PermissionGuard em cada endpoint continua sendo a única fonte
// confiável de autorização.
permissionKeys: await getUserPermissionKeys(user.sub, user.tenantId),
};
}
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Get("tenants") @Get("tenants")
async tenants(@CurrentUser() user: AccessTokenClaims) { async tenants(@CurrentUser() user: AccessTokenClaims) {

View File

@@ -0,0 +1,74 @@
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import { createLogger } from "@b2bcall/shared";
const logger = createLogger("b2bcall-api");
type Meter = "EXTENSION_ACTIVE_DAY" | "AGENT_ACTIVE_DAY" | "TRUNK_ACTIVE_DAY";
async function recordDailyMeter(
tx: Prisma.TransactionClient,
tenantId: string,
meter: Meter,
sourceType: string,
rows: Array<{ id: string }>,
todayStart: Date,
todayEnd: Date,
): Promise<void> {
for (const row of rows) {
// Sem constraint unica no banco pra (tenantId, meter, sourceId, dia) —
// checagem explicita antes do insert. Corrida real possivel se a
// varredura rodar 2x em paralelo pro mesmo tenant (nao acontece hoje,
// um unico processo apps/api chama isso num setInterval sequencial),
// documentado como limitacao conhecida em docs/BILLING.md.
const exists = await tx.usageEvent.findFirst({
where: { tenantId, meter, sourceId: row.id, occurredAt: { gte: todayStart, lt: todayEnd } },
});
if (exists) continue;
await tx.usageEvent.create({
data: {
tenantId,
meter,
quantity: 1,
unit: "day",
sourceType,
sourceId: row.id,
occurredAt: todayStart,
},
});
}
}
/**
* "EXTENSION_ACTIVE_DAY"/"AGENT_ACTIVE_DAY"/"TRUNK_ACTIVE_DAY" (agente.md
* secao 131) — 1 `UsageEvent` por recurso ativo por dia, consumido pelo
* `BillingEngineService` no fechamento (`rateActiveDaysProrated`). Roda no
* boot + de hora em hora (mesmo padrão de `runRetentionSweep`) — idempotente
* dentro do mesmo dia (não duplica se já rodou hoje pra aquele recurso).
*/
export async function runActiveDaySweep(): Promise<void> {
const prisma = getPrismaClient();
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const todayEnd = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
const tenants = await prisma.tenant.findMany({ where: { status: "ACTIVE" }, select: { id: true } });
for (const tenant of tenants) {
try {
await withTenantContext(prisma, tenant.id, async (tx) => {
const [extensions, agents, trunks] = await Promise.all([
tx.extension.findMany({ where: { tenantId: tenant.id, deletedAt: null }, select: { id: true } }),
tx.agent.findMany({ where: { tenantId: tenant.id, deletedAt: null }, select: { id: true } }),
tx.trunk.findMany({ where: { tenantId: tenant.id, deletedAt: null }, select: { id: true } }),
]);
await recordDailyMeter(tx, tenant.id, "EXTENSION_ACTIVE_DAY", "extension", extensions, todayStart, todayEnd);
await recordDailyMeter(tx, tenant.id, "AGENT_ACTIVE_DAY", "agent", agents, todayStart, todayEnd);
await recordDailyMeter(tx, tenant.id, "TRUNK_ACTIVE_DAY", "trunk", trunks, todayStart, todayEnd);
});
} catch (err) {
logger.error("falha na varredura de uso diario (billing)", { error: String(err), tenantId: tenant.id });
}
}
}

View File

@@ -0,0 +1,67 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "Billing > Consumo" (agente.md secao 169) — a mesma agregação de
* `/reports/consumo` (tenant, mês corrente), só que em TODOS os tenants
* de uma vez. Nunca calcula valor em dinheiro (isso é o RatingEngine/
* BillingStatement — Billing > Relatórios), só a quantidade bruta dos 2
* ledgers imutáveis. Diferente de Clientes > Quotas: aqui é "quanto cada
* tenant consumiu", lá é "quanto sobra até o limite do plano".
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/consumo")
export class BillingConsumoController {
@RequirePermission("billing.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver consumo de todos os tenants");
}
const prisma = getPrismaClient();
const now = new Date();
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const tenants = await prisma.tenant.findMany({ where: { deletedAt: null }, orderBy: { legalName: "asc" } });
return Promise.all(
tenants.map(async (tenant) => {
const [usageByMeter, aiUsageByType] = await withTenantContext(prisma, tenant.id, (tx) =>
Promise.all([
tx.usageEvent.groupBy({ by: ["meter"], where: { tenantId: tenant.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId: tenant.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
]),
);
const usage: Record<string, number> = {};
for (const row of usageByMeter) usage[row.meter] = row._sum.quantity ?? 0;
const aiUsage: Record<string, number> = {};
for (const row of aiUsageByType) aiUsage[row.type] = row._sum.quantity ?? 0;
return {
tenantId: tenant.id,
legalName: tenant.legalName,
usage: {
callCount: usage["CALL_COUNT"] ?? 0,
callSeconds: usage["CALL_SECONDS"] ?? 0,
extensionActiveDays: usage["EXTENSION_ACTIVE_DAY"] ?? 0,
agentActiveDays: usage["AGENT_ACTIVE_DAY"] ?? 0,
trunkActiveDays: usage["TRUNK_ACTIVE_DAY"] ?? 0,
recordingBytes: usage["RECORDING_BYTES"] ?? 0,
},
aiUsage: {
transcriptionSeconds: aiUsage["AI_TRANSCRIPTION_SECONDS"] ?? 0,
analysisRequests: aiUsage["AI_ANALYSIS_REQUEST"] ?? 0,
inputTokens: aiUsage["AI_INPUT_TOKENS"] ?? 0,
outputTokens: aiUsage["AI_OUTPUT_TOKENS"] ?? 0,
},
};
}),
);
}
}

View File

@@ -0,0 +1,330 @@
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma, type PriceItemType } from "@b2bcall/database";
import {
resolvePriceBookItem,
rateCallFlatFallback,
rateGenericUsage,
rateActiveDaysProrated,
rateTranscriptionSeconds,
rateRecordingBytes,
type PriceBookItemLike,
} from "@b2bcall/billing";
import { recordAuditEvent } from "@b2bcall/auth";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** UsageMeter -> PriceItemType, pra métricas do tipo "N dias ativo" (agente.md
* secao 131) — preço mensal do item, prorateado por `rateActiveDaysProrated`. */
const ACTIVE_DAY_METER_TO_PRICE_TYPE: Record<string, PriceItemType> = {
EXTENSION_ACTIVE_DAY: "EXTENSION_MONTH",
AGENT_ACTIVE_DAY: "AGENT_MONTH",
TRUNK_ACTIVE_DAY: "TRUNK_MONTH",
};
/** AIUsageType -> PriceItemType (secao 124/131 — nomes não batem 1:1,
* "SECONDS"/"REQUEST"/plural de token na origem viram "MINUTE"/"CALL"/
* singular no catálogo de preço). */
const AI_USAGE_TYPE_TO_PRICE_TYPE: Record<string, PriceItemType> = {
AI_TRANSCRIPTION_SECONDS: "AI_TRANSCRIPTION_MINUTE",
AI_ANALYSIS_REQUEST: "AI_ANALYSIS_CALL",
AI_INPUT_TOKENS: "AI_INPUT_TOKEN",
AI_OUTPUT_TOKENS: "AI_OUTPUT_TOKEN",
};
/** PriceItemType -> BillingStatementCategory, pra agrupar `RatedUsageItem`s
* na linha do statement (agente.md secao 136). */
const PRICE_TYPE_TO_CATEGORY: Record<PriceItemType, string> = {
BASE_SUBSCRIPTION: "PLAN_BASE",
EXTENSION_MONTH: "EXTENSIONS",
AGENT_MONTH: "AGENTS",
TRUNK_MONTH: "TRUNKS",
CALL: "CALLS",
CALL_MINUTE: "MINUTES",
FIXED_MINUTE: "MINUTES",
MOBILE_MINUTE: "MINUTES",
INTERNATIONAL_MINUTE: "MINUTES",
AI_TRANSCRIPTION_MINUTE: "AI_TRANSCRIPTION",
AI_ANALYSIS_CALL: "AI_ANALYSIS",
AI_INPUT_TOKEN: "AI_TOKENS",
AI_OUTPUT_TOKEN: "AI_TOKENS",
RECORDING_GB_MONTH: "STORAGE",
};
const CATEGORY_LABEL: Record<string, string> = {
PLAN_BASE: "Assinatura do plano",
EXTENSIONS: "Ramais ativos",
AGENTS: "Agentes ativos",
TRUNKS: "Troncos ativos",
CALLS: "Chamadas",
MINUTES: "Minutos de chamada",
AI_TRANSCRIPTION: "Transcricao (IA)",
AI_ANALYSIS: "Analise de chamada (IA)",
AI_TOKENS: "Tokens (IA)",
STORAGE: "Armazenamento de gravacoes",
ADJUSTMENT: "Ajuste",
};
/**
* RatingEngine é matemática pura (packages/billing, sem I/O — ver
* comentário em rating-engine.ts); este service faz a orquestração real
* (agente.md secao 130-137): resolve catálogos vigentes, lê os 2 ledgers
* imutáveis (`UsageEvent`+`AIUsageRecord`), grava `RatedUsageItem` (1 por
* evento — nunca agrega antes de ratear, "immutable usage ledger" secao
* 233) e fecha em `BillingStatement`/`BillingStatementItem` (agregado por
* categoria, o que o tenant efetivamente vê).
*
* **Lacuna real, conhecida**: `Call.calledNumber` ainda não é populado
* pelo CDR (ver TODO.md PHASE 17) — não dá pra fazer o longest-prefix
* match do RateDeck (secao 129) por destino real. `CALL_SECONDS` sempre
* usa `rateCallFlatFallback` (PriceBookItem `CALL_MINUTE`) por enquanto;
* `RateDeck`/`longestPrefixMatch` ficam cadastráveis e testados
* isoladamente (packages/billing tem teste unitário), só não são
* exercitados ponta a ponta até essa lacuna fechar.
*/
export async function closeBillingPeriod(opts: {
tenantId: string;
periodStart: Date;
periodEnd: Date;
userId: string;
}): Promise<{ periodId: string; statementId: string; total: number }> {
const { tenantId, periodStart, periodEnd, userId } = opts;
if (periodStart >= periodEnd) {
throw new BadRequestException("periodStart deve ser anterior a periodEnd");
}
const daysInPeriod = (periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY;
const prisma = getPrismaClient();
const result = await withTenantContext(prisma, tenantId, async (tx) => {
const existing = await tx.billingPeriod.findUnique({
where: { tenantId_periodStart_periodEnd: { tenantId, periodStart, periodEnd } },
});
if (existing?.status === "CLOSED") {
throw new ConflictException("Periodo ja fechado (secao 137) — reabra explicitamente antes de recalcular");
}
if (existing?.status === "CALCULATING") {
throw new ConflictException("Fechamento ja em andamento para este periodo");
}
const period = existing
? await tx.billingPeriod.update({ where: { id: existing.id }, data: { status: "CALCULATING" } })
: await tx.billingPeriod.create({ data: { tenantId, periodStart, periodEnd, status: "CALCULATING" } });
const tenant = await tx.tenant.findUniqueOrThrow({ where: { id: tenantId } });
const priceBook = await tx.priceBook.findFirst({
where: tenant.priceBookId ? { id: tenant.priceBookId } : { isDefault: true },
include: { items: true },
});
if (!priceBook) {
throw new ConflictException("Nenhum PriceBook configurado (nem default) para tarifar este tenant");
}
const priceItems: PriceBookItemLike[] = priceBook.items;
const callMinuteItem = resolvePriceBookItem(priceItems, "CALL_MINUTE", periodEnd);
const [usageEvents, aiUsageRecords] = await Promise.all([
tx.usageEvent.findMany({
where: { tenantId, occurredAt: { gte: periodStart, lt: periodEnd }, ratedUsageItems: { none: {} } },
}),
tx.aIUsageRecord.findMany({
where: { tenantId, occurredAt: { gte: periodStart, lt: periodEnd }, ratedUsageItems: { none: {} } },
}),
]);
const ratedItemsData: Prisma.RatedUsageItemCreateManyInput[] = [];
for (const ev of usageEvents) {
if (ev.meter === "CALL_SECONDS") {
if (!callMinuteItem) continue; // sem preco/minuto configurado — nao cobra, nao inventa preco
const rated = rateCallFlatFallback(ev.quantity, callMinuteItem);
ratedItemsData.push({
tenantId,
usageEventId: ev.id,
callId: ev.callId,
priceBookItemId: callMinuteItem.id,
quantity: rated.ratedMinutes,
unitPrice: rated.destinationRate,
amount: rated.ratedAmount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
continue;
}
const priceType = ACTIVE_DAY_METER_TO_PRICE_TYPE[ev.meter];
if (!priceType) continue; // RECORDING_BYTES e os meters de IA nao viram UsageEvent (ver cdr.ts / process-*.ts)
const item = resolvePriceBookItem(priceItems, priceType, periodEnd);
if (!item) continue;
const amount = rateActiveDaysProrated(ev.quantity, item.unitPrice, daysInPeriod);
ratedItemsData.push({
tenantId,
usageEventId: ev.id,
priceBookItemId: item.id,
quantity: ev.quantity,
unitPrice: daysInPeriod > 0 ? item.unitPrice / daysInPeriod : 0,
amount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
}
for (const rec of aiUsageRecords) {
const priceType = AI_USAGE_TYPE_TO_PRICE_TYPE[rec.type];
const item = resolvePriceBookItem(priceItems, priceType, periodEnd);
if (!item) continue;
const amount =
rec.type === "AI_TRANSCRIPTION_SECONDS"
? rateTranscriptionSeconds(rec.quantity, item.unitPrice)
: rateGenericUsage(rec.quantity, item.unitPrice);
ratedItemsData.push({
tenantId,
aiUsageRecordId: rec.id,
priceBookItemId: item.id,
quantity: rec.quantity,
unitPrice: item.unitPrice,
amount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
}
// RECORDING_BYTES (secao 131): sem ledger de eventos próprio (ver
// comentário em rating-engine.ts) — usa os bytes armazenados AGORA como
// proxy do consumo do período inteiro, decisão documentada em
// docs/BILLING.md.
const storageItem = resolvePriceBookItem(priceItems, "RECORDING_GB_MONTH", periodEnd);
if (storageItem) {
const recordingAgg = await tx.recording.aggregate({
where: { tenantId, status: "AVAILABLE" },
_sum: { sizeBytes: true },
});
const bytes = Number(recordingAgg._sum.sizeBytes ?? 0n);
if (bytes > 0) {
const amount = rateRecordingBytes(bytes, storageItem.unitPrice);
ratedItemsData.push({
tenantId,
priceBookItemId: storageItem.id,
quantity: bytes / 1_000_000_000,
unitPrice: storageItem.unitPrice,
amount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
}
}
if (ratedItemsData.length > 0) {
await tx.ratedUsageItem.createMany({ data: ratedItemsData });
}
const ratedItems = await tx.ratedUsageItem.findMany({
where: { billingPeriodId: period.id },
include: { priceBookItem: true },
});
const categoryTotals = new Map<string, number>();
for (const item of ratedItems) {
const category = item.priceBookItem ? PRICE_TYPE_TO_CATEGORY[item.priceBookItem.type] : "ADJUSTMENT";
categoryTotals.set(category, (categoryTotals.get(category) ?? 0) + item.amount);
}
const subscription = await tx.tenantSubscription.findFirst({
where: { tenantId, status: { in: ["ACTIVE", "TRIALING"] } },
include: { planVersion: true },
orderBy: { startedAt: "desc" },
});
if (subscription && subscription.planVersion.basePrice > 0) {
categoryTotals.set("PLAN_BASE", (categoryTotals.get("PLAN_BASE") ?? 0) + subscription.planVersion.basePrice);
}
const subtotal = [...categoryTotals.values()].reduce((sum, v) => sum + v, 0);
const currency = subscription?.currency ?? priceBook.currency;
const statement = await tx.billingStatement.create({
data: {
tenantId,
billingPeriodId: period.id,
currency,
subtotal,
adjustments: 0,
total: subtotal,
items: {
create: [...categoryTotals.entries()].map(([category, amount]) => ({
tenantId,
category: category as never,
description: CATEGORY_LABEL[category] ?? category,
amount,
})),
},
},
});
await tx.billingPeriod.update({
where: { id: period.id },
data: { status: "CLOSED", closedAt: new Date() },
});
return { periodId: period.id, statementId: statement.id, total: statement.total, ratedCount: ratedItems.length };
});
await recordAuditEvent(prisma, {
action: "BILLING_PERIOD_CLOSE",
tenantId,
userId,
entityType: "billing_period",
entityId: result.periodId,
after: { total: result.total, ratedItemCount: result.ratedCount },
});
return result;
}
/**
* "Reabrir" um período fechado pra corrigir e recalcular (secao 137): nunca
* volta direto pra OPEN — vira REOPENED, com audit trail (user+motivo),
* deixando visível no histórico que esse período já foi fechado antes.
* `closeBillingPeriod` aceita rodar de novo em cima de um período
* REOPENED (só bloqueia CLOSED/CALCULATING).
*
* **Bug real, achado no teste desta fase**: a primeira versão lia o
* período com `prisma.billingPeriod.findUniqueOrThrow({ where: { id } })`
* SEM tenant context pra descobrir o `tenantId` — mas `billing_periods` tem
* FORCE ROW LEVEL SECURITY (agente.md secao 30), então a leitura sem
* `app.current_tenant_id` não vê a linha, e o "not found" virava 500 (P2025
* não mapeado, nunca um 404 de verdade). `tenantId` precisa vir explícito
* no request (mesma exceção já aplicada em `closeBillingPeriod`), nunca
* descoberto lendo a própria tabela protegida por RLS.
*/
export async function reopenBillingPeriod(opts: {
periodId: string;
tenantId: string;
userId: string;
reason: string;
}): Promise<void> {
const prisma = getPrismaClient();
const period = await withTenantContext(prisma, opts.tenantId, (tx) =>
tx.billingPeriod.findUnique({ where: { id: opts.periodId } }),
);
if (!period || period.tenantId !== opts.tenantId) {
throw new NotFoundException("Periodo de billing nao encontrado para este tenant");
}
if (period.status !== "CLOSED") {
throw new ConflictException("So' e' possivel reabrir um periodo CLOSED");
}
await withTenantContext(prisma, opts.tenantId, (tx) =>
tx.billingPeriod.update({
where: { id: period.id },
data: { status: "REOPENED", reopenedAt: new Date() },
}),
);
await recordAuditEvent(prisma, {
action: "BILLING_PERIOD_REOPEN",
tenantId: period.tenantId,
userId: opts.userId,
entityType: "billing_period",
entityId: period.id,
after: { reason: opts.reason },
});
}

View File

@@ -0,0 +1,72 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ClosePeriodDto } from "./dto/close-period.dto";
import { ReopenPeriodDto } from "./dto/reopen-period.dto";
import { closeBillingPeriod, reopenBillingPeriod } from "./billing-engine.service";
/**
* "Fechamentos" (PRODUCT.md, menu Platform > Billing). Fechar/reabrir um
* período é uma ação de platform admin sobre um tenant arbitrário — por
* isso `tenantId` vem no body em vez de vir só do JWT (mesma exceção já
* aplicada a GLOBAL em AIProvider/AIPromptTemplate: `isPlatformUser`
* checado explicitamente na camada de serviço, nunca confiado só na
* permission). Ver histórico do fechamento em `GET /billing/periods` (o
* próprio tenant, escopo do seu JWT).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/periods")
export class BillingPeriodsController {
@RequirePermission("billing.manage")
@Post("close")
async close(@CurrentUser() user: AccessTokenClaims, @Body() dto: ClosePeriodDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode fechar um periodo de billing");
}
return closeBillingPeriod({
tenantId: dto.tenantId,
periodStart: new Date(dto.periodStart),
periodEnd: new Date(dto.periodEnd),
userId: user.sub,
});
}
@RequirePermission("billing.manage")
@Post(":id/reopen")
async reopen(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: ReopenPeriodDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode reabrir um periodo de billing");
}
await reopenBillingPeriod({ periodId: id, tenantId: dto.tenantId, userId: user.sub, reason: dto.reason });
return { reopened: true };
}
@RequirePermission("billing.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const tenantId = user.tenantId!;
const prisma = getPrismaClient();
return withTenantContext(prisma, tenantId, (tx) =>
tx.billingPeriod.findMany({ where: { tenantId }, orderBy: { periodStart: "desc" } }),
);
}
/** Platform admin olhando um tenant arbitrário (secao 168, "Billing >
* Fechamentos") — `GET /billing/periods` acima só serve o próprio
* tenant do JWT, que um platform admin não tem. */
@RequirePermission("billing.manage")
@Get("by-tenant/:tenantId")
async listByTenant(@CurrentUser() user: AccessTokenClaims, @Param("tenantId") tenantId: string) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver fechamentos de outro tenant");
}
const prisma = getPrismaClient();
return withTenantContext(prisma, tenantId, (tx) =>
tx.billingPeriod.findMany({ where: { tenantId }, orderBy: { periodStart: "desc" } }),
);
}
}

View File

@@ -0,0 +1,75 @@
import { Controller, ForbiddenException, Get, NotFoundException, Param, Query, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "Billing Statement" / "Relatorio de Consumo" (agente.md secao 138-139) —
* nunca chamado de "invoice"/"nota fiscal" na UI (PRODUCT.md, Operating
* Context). Sempre escopado ao próprio tenant do JWT (secao 31) — nunca um
* id de statement de outro tenant, RLS + WHERE tenantId garantem os dois.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/statements")
export class BillingStatementsController {
@RequirePermission("billing.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const tenantId = user.tenantId!;
const prisma = getPrismaClient();
return withTenantContext(prisma, tenantId, (tx) =>
tx.billingStatement.findMany({
where: { tenantId },
include: { billingPeriod: true },
orderBy: { generatedAt: "desc" },
}),
);
}
/** Platform admin olhando um tenant arbitrário (secao 168, "Billing >
* Relatórios") — `GET /billing/statements` acima só serve o próprio
* tenant do JWT, que um platform admin não tem. */
@RequirePermission("billing.manage")
@Get("by-tenant/:tenantId")
async listByTenant(@CurrentUser() user: AccessTokenClaims, @Param("tenantId") tenantId: string) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver relatorios de outro tenant");
}
const prisma = getPrismaClient();
return withTenantContext(prisma, tenantId, (tx) =>
tx.billingStatement.findMany({
where: { tenantId },
include: { billingPeriod: true },
orderBy: { generatedAt: "desc" },
}),
);
}
/** `tenantId` na query só é aceito de quem tem role de plataforma (secao
* 31: nunca confiar em tenant vindo do client sem checar) — um tenant
* admin comum sempre olha só o próprio, do JWT, mesmo que tente mandar
* outro. */
@RequirePermission("billing.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Query("tenantId") queryTenantId?: string) {
let tenantId = user.tenantId!;
if (queryTenantId && queryTenantId !== tenantId) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver statement de outro tenant");
}
tenantId = queryTenantId;
}
const prisma = getPrismaClient();
const statement = await withTenantContext(prisma, tenantId, (tx) =>
tx.billingStatement.findFirst({
where: { id, tenantId },
include: { billingPeriod: true, items: true },
}),
);
if (!statement) throw new NotFoundException();
return statement;
}
}

View File

@@ -0,0 +1,21 @@
import { Module } from "@nestjs/common";
import { PriceBooksController } from "./price-books.controller";
import { RateDecksController } from "./rate-decks.controller";
import { PlanVersionsController } from "./plan-versions.controller";
import { SubscriptionsController } from "./subscriptions.controller";
import { BillingPeriodsController } from "./billing-periods.controller";
import { BillingStatementsController } from "./billing-statements.controller";
import { BillingConsumoController } from "./billing-consumo.controller";
@Module({
controllers: [
PriceBooksController,
RateDecksController,
PlanVersionsController,
SubscriptionsController,
BillingPeriodsController,
BillingStatementsController,
BillingConsumoController,
],
})
export class BillingModule {}

View File

@@ -0,0 +1,12 @@
import { IsDateString, IsUUID } from "class-validator";
export class ClosePeriodDto {
@IsUUID()
tenantId!: string;
@IsDateString()
periodStart!: string;
@IsDateString()
periodEnd!: string;
}

View File

@@ -0,0 +1,22 @@
import { IsDateString, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from "class-validator";
export class CreatePlanVersionDto {
@IsUUID()
planId!: string;
@IsNumber()
@Min(0)
basePrice!: number;
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@IsDateString()
effectiveFrom!: string;
@IsOptional()
@IsDateString()
effectiveUntil?: string;
}

View File

@@ -0,0 +1,55 @@
import { IsArray, IsBoolean, IsDateString, IsIn, IsNumber, IsOptional, IsString, MaxLength, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
const PRICE_ITEM_TYPES = [
"BASE_SUBSCRIPTION",
"EXTENSION_MONTH",
"AGENT_MONTH",
"TRUNK_MONTH",
"CALL",
"CALL_MINUTE",
"FIXED_MINUTE",
"MOBILE_MINUTE",
"INTERNATIONAL_MINUTE",
"AI_TRANSCRIPTION_MINUTE",
"AI_ANALYSIS_CALL",
"AI_INPUT_TOKEN",
"AI_OUTPUT_TOKEN",
"RECORDING_GB_MONTH",
] as const;
export class CreatePriceBookItemDto {
@IsIn(PRICE_ITEM_TYPES)
type!: (typeof PRICE_ITEM_TYPES)[number];
@IsNumber()
unitPrice!: number;
@IsDateString()
effectiveFrom!: string;
@IsOptional()
@IsDateString()
effectiveUntil?: string;
}
export class CreatePriceBookDto {
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreatePriceBookItemDto)
items?: CreatePriceBookItemDto[];
}

View File

@@ -0,0 +1,60 @@
import { IsArray, IsBoolean, IsDateString, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
const DESTINATION_TYPES = ["FIXED", "MOBILE", "INTERNATIONAL"] as const;
export class CreateRateDeckEntryDto {
@IsString()
@MaxLength(20)
prefix!: string;
@IsString()
@MaxLength(80)
destinationName!: string;
@IsIn(DESTINATION_TYPES)
destinationType!: (typeof DESTINATION_TYPES)[number];
@IsNumber()
pricePerMinute!: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(3600)
billingIncrementSeconds?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(3600)
minimumSeconds?: number;
@IsOptional()
@IsNumber()
@Min(0)
connectionFee?: number;
@IsDateString()
validFrom!: string;
@IsOptional()
@IsDateString()
validUntil?: string;
}
export class CreateRateDeckDto {
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateRateDeckEntryDto)
entries?: CreateRateDeckEntryDto[];
}

View File

@@ -0,0 +1,23 @@
import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator";
export class CreateSubscriptionDto {
@IsUUID()
tenantId!: string;
@IsUUID()
planVersionId!: string;
@IsOptional()
@IsDateString()
startedAt?: string;
@IsInt()
@Min(1)
@Max(28)
billingCycleAnchor!: number;
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsUUID, MaxLength, MinLength } from "class-validator";
export class ReopenPeriodDto {
@IsUUID()
tenantId!: string;
@IsString()
@MinLength(3)
@MaxLength(500)
reason!: string;
}

View File

@@ -0,0 +1,66 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreatePlanVersionDto } from "./dto/create-plan-version.dto";
/**
* "plan_versions" (agente.md secao 126: "Precos e limites devem ser
* versionados"). Versiona só o preço base — ver comentário no schema.
* `version` é sempre a proxima sequencial do plano (nunca escolhida pelo
* client, agente.md secao 233: nunca confiar em input do client pra
* invariante do sistema).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/plan-versions")
export class PlanVersionsController {
@RequirePermission("pricing.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePlanVersionDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode versionar planos");
}
const prisma = getPrismaClient();
const plan = await prisma.plan.findUnique({ where: { id: dto.planId } });
if (!plan) throw new ForbiddenException("Plano nao encontrado");
const lastVersion = await prisma.planVersion.findFirst({
where: { planId: dto.planId },
orderBy: { version: "desc" },
});
const nextVersion = (lastVersion?.version ?? 0) + 1;
const planVersion = await prisma.planVersion.create({
data: {
planId: dto.planId,
version: nextVersion,
basePrice: dto.basePrice,
currency: dto.currency ?? "BRL",
effectiveFrom: new Date(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
},
});
await recordAuditEvent(prisma, {
action: "PLAN_VERSION_CREATE",
tenantId: null,
userId: user.sub,
entityType: "plan_version",
entityId: planVersion.id,
after: { planId: plan.id, version: planVersion.version, basePrice: planVersion.basePrice },
});
return planVersion;
}
@RequirePermission("pricing.manage")
@Get("by-plan/:planId")
async listByPlan(@Param("planId") planId: string) {
const prisma = getPrismaClient();
return prisma.planVersion.findMany({ where: { planId }, orderBy: { version: "desc" } });
}
}

View File

@@ -0,0 +1,78 @@
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreatePriceBookDto } from "./dto/create-price-book.dto";
async function assertPlatformUser(userId: string): Promise<void> {
if (!(await isPlatformUser(userId))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode gerenciar price books");
}
}
/**
* "price_books"/"price_book_items" (agente.md secao 128) — catálogo
* global da plataforma, sem tenant_id (mesmo padrão de `Plan`), gerenciado
* só por platform admin. Um `Tenant` escolhe qual usar via
* `Tenant.priceBookId` (null = o que tiver `isDefault=true`); a atribuição
* em si é uma ação de `tenants.manage`, fora do escopo deste controller.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/price-books")
export class PriceBooksController {
@RequirePermission("pricing.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePriceBookDto) {
await assertPlatformUser(user.sub);
const prisma = getPrismaClient();
const priceBook = await prisma.priceBook.create({
data: {
name: dto.name,
currency: dto.currency ?? "BRL",
isDefault: dto.isDefault ?? false,
items: dto.items
? {
create: dto.items.map((item) => ({
type: item.type,
unitPrice: item.unitPrice,
effectiveFrom: new Date(item.effectiveFrom),
effectiveUntil: item.effectiveUntil ? new Date(item.effectiveUntil) : null,
})),
}
: undefined,
},
include: { items: true },
});
await recordAuditEvent(prisma, {
action: "PRICE_BOOK_CREATE",
tenantId: null,
userId: user.sub,
entityType: "price_book",
entityId: priceBook.id,
after: { name: priceBook.name, isDefault: priceBook.isDefault },
});
return priceBook;
}
@RequirePermission("pricing.manage")
@Get()
async list() {
const prisma = getPrismaClient();
return prisma.priceBook.findMany({ include: { items: true }, orderBy: { name: "asc" } });
}
@RequirePermission("pricing.manage")
@Get(":id")
async get(@Param("id") id: string) {
const prisma = getPrismaClient();
const priceBook = await prisma.priceBook.findUnique({ where: { id }, include: { items: true } });
if (!priceBook) throw new NotFoundException();
return priceBook;
}
}

View File

@@ -0,0 +1,84 @@
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateRateDeckDto } from "./dto/create-rate-deck.dto";
async function assertPlatformUser(userId: string): Promise<void> {
if (!(await isPlatformUser(userId))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode gerenciar rate decks");
}
}
/**
* "rate_decks"/"rate_deck_entries" (agente.md secao 129) — catálogo global
* de tarifas por prefixo de destino, mesmo padrão de escopo de
* `PriceBooksController`. `RatingEngine.longestPrefixMatch`
* (packages/billing) consome `entries` pra tarifar `CALL_SECONDS` por
* destino — hoje o fallback plano é sempre usado (ver
* `BillingEngineService`, `Call.calledNumber` ainda não é populado pelo
* CDR), mas o cadastro fica disponível pra quando essa lacuna for fechada.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/rate-decks")
export class RateDecksController {
@RequirePermission("pricing.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateRateDeckDto) {
await assertPlatformUser(user.sub);
const prisma = getPrismaClient();
const rateDeck = await prisma.rateDeck.create({
data: {
name: dto.name,
isDefault: dto.isDefault ?? false,
entries: dto.entries
? {
create: dto.entries.map((entry) => ({
prefix: entry.prefix,
destinationName: entry.destinationName,
destinationType: entry.destinationType,
pricePerMinute: entry.pricePerMinute,
billingIncrementSeconds: entry.billingIncrementSeconds ?? 60,
minimumSeconds: entry.minimumSeconds ?? 0,
connectionFee: entry.connectionFee ?? 0,
validFrom: new Date(entry.validFrom),
validUntil: entry.validUntil ? new Date(entry.validUntil) : null,
})),
}
: undefined,
},
include: { entries: true },
});
await recordAuditEvent(prisma, {
action: "RATE_DECK_CREATE",
tenantId: null,
userId: user.sub,
entityType: "rate_deck",
entityId: rateDeck.id,
after: { name: rateDeck.name, isDefault: rateDeck.isDefault },
});
return rateDeck;
}
@RequirePermission("pricing.manage")
@Get()
async list() {
const prisma = getPrismaClient();
return prisma.rateDeck.findMany({ include: { entries: true }, orderBy: { name: "asc" } });
}
@RequirePermission("pricing.manage")
@Get(":id")
async get(@Param("id") id: string) {
const prisma = getPrismaClient();
const rateDeck = await prisma.rateDeck.findUnique({ where: { id }, include: { entries: true } });
if (!rateDeck) throw new NotFoundException();
return rateDeck;
}
}

View File

@@ -0,0 +1,75 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateSubscriptionDto } from "./dto/create-subscription.dto";
/**
* "tenant_subscriptions" (agente.md secao 127) — qual `PlanVersion` (preço
* vigente) um tenant assinou e em que dia do mês fecha o período de
* billing dele (`billingCycleAnchor`). Ação de platform admin (o tenant
* não escolhe o próprio preço); um tenant pode ter várias linhas ao longo
* do tempo (histórico de mudança de plano/preço), nunca UPDATE no preço de
* uma assinatura já ativa — sempre uma nova linha.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/subscriptions")
export class SubscriptionsController {
@RequirePermission("billing.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateSubscriptionDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode gerenciar assinaturas");
}
const prisma = getPrismaClient();
const planVersion = await prisma.planVersion.findUnique({ where: { id: dto.planVersionId } });
if (!planVersion) throw new ForbiddenException("PlanVersion nao encontrada");
// `tenant_subscriptions` tem RLS (tenant-scoped) mesmo essa sendo uma
// ação de platform admin sobre um tenant arbitrário — precisa do
// contexto igual a qualquer outra escrita tenant-scoped (secao 30).
const subscription = await withTenantContext(prisma, dto.tenantId, (tx) =>
tx.tenantSubscription.create({
data: {
tenantId: dto.tenantId,
planVersionId: dto.planVersionId,
status: "ACTIVE",
startedAt: dto.startedAt ? new Date(dto.startedAt) : new Date(),
billingCycleAnchor: dto.billingCycleAnchor,
currency: dto.currency ?? planVersion.currency,
},
}),
);
await recordAuditEvent(prisma, {
action: "TENANT_SUBSCRIPTION_CREATE",
tenantId: dto.tenantId,
userId: user.sub,
entityType: "tenant_subscription",
entityId: subscription.id,
after: { planVersionId: subscription.planVersionId, billingCycleAnchor: subscription.billingCycleAnchor },
});
return subscription;
}
@RequirePermission("billing.manage")
@Get("by-tenant/:tenantId")
async listByTenant(@CurrentUser() user: AccessTokenClaims, @Param("tenantId") tenantId: string) {
if (!(await isPlatformUser(user.sub)) && user.tenantId !== tenantId) {
throw new ForbiddenException("Sem acesso as assinaturas deste tenant");
}
const prisma = getPrismaClient();
return withTenantContext(prisma, tenantId, (tx) =>
tx.tenantSubscription.findMany({
where: { tenantId },
include: { planVersion: true },
orderBy: { startedAt: "desc" },
}),
);
}
}

View File

@@ -5,10 +5,21 @@ import { PERMISSION_KEY } from "../decorators/require-permission.decorator";
import type { AuthenticatedRequest } from "./jwt-auth.guard"; import type { AuthenticatedRequest } from "./jwt-auth.guard";
/** /**
* Roda depois do JwtAuthGuard. Exige que a rota tenha um tenant selecionado * Roda depois do JwtAuthGuard. Exige que o usuário tenha a permission
* (agente.md secao 31: nunca confiar em tenant_id do frontend — aqui vem só * marcada via @RequirePermission() (secao 145/146), resolvida sempre a
* do JWT, nunca do body/query) e que o usuário tenha a permission marcada * partir do JWT (secao 31: nunca confiar em tenant_id do frontend).
* via @RequirePermission() (secao 145/146). *
* Um usuário PLATFORM puro (sem NENHUMA TenantMembership — ex.: o platform
* super admin recém-criado) nunca tem um tenant pra selecionar em
* `/auth/select-tenant` (`/auth/tenants` retorna vazio pra ele), então
* `user.tenantId` legitimamente nunca vai existir no token dele. Bug real
* encontrado testando `PlatformOverviewController` (agente.md secao 163):
* antes desta correção, exigir `tenantId` incondicionalmente deixava
* QUALQUER endpoint com @RequirePermission inacessível pra esse usuário,
* mesmo os platform-only. Corrigido: sem tenantId, ainda tenta a permission
* em escopo PLATFORM (`userHasPermission` com tenantId undefined já filtra
* só roles com tenantId null); só barra de fato quem não tem a permission
* em nenhum escopo.
*/ */
@Injectable() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
@@ -29,13 +40,14 @@ export class PermissionGuard implements CanActivate {
if (!user) { if (!user) {
throw new ForbiddenException("Nao autenticado"); throw new ForbiddenException("Nao autenticado");
} }
if (!user.tenantId) {
throw new ForbiddenException("Nenhum tenant selecionado (use /auth/select-tenant)");
}
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId); const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId ?? undefined);
if (!allowed) { if (!allowed) {
throw new ForbiddenException(`Permissao necessaria: ${permissionKey}`); throw new ForbiddenException(
user.tenantId
? `Permissao necessaria: ${permissionKey}`
: `Permissao necessaria: ${permissionKey} (nenhum tenant selecionado, checado so' em escopo PLATFORM — use /auth/select-tenant se a permissao for de tenant)`,
);
} }
return true; return true;

View File

@@ -11,11 +11,7 @@ import {
import { XMLValidator } from "fast-xml-parser"; import { XMLValidator } from "fast-xml-parser";
import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { import { buildDialplanXml, type AllowedDialplanApplication } from "@b2bcall/telephony";
buildDialplanXml,
type AllowedConditionField,
type AllowedDialplanApplication,
} from "@b2bcall/telephony";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard"; import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator"; import { RequirePermission } from "../common/decorators/require-permission.decorator";
@@ -47,7 +43,7 @@ export class DialplanVersionsController {
context, context,
extensions.map((e) => ({ extensions.map((e) => ({
name: e.name, name: e.name,
conditionField: e.conditionField as AllowedConditionField, conditionField: e.conditionField,
conditionExpr: e.conditionExpr, conditionExpr: e.conditionExpr,
actions: e.actions as unknown as { application: AllowedDialplanApplication; data?: string }[], actions: e.actions as unknown as { application: AllowedDialplanApplication; data?: string }[],
antiActions: e.antiActions as unknown as antiActions: e.antiActions as unknown as

View File

@@ -1,5 +1,6 @@
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator"; import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
import { ALLOWED_DIALPLAN_APPLICATIONS, type AllowedDialplanApplication } from "@b2bcall/telephony"; import { ALLOWED_DIALPLAN_APPLICATIONS, type AllowedDialplanApplication } from "@b2bcall/telephony";
import { IsSafeDialplanData } from "./safe-dialplan-data.validator";
export class ActionDto { export class ActionDto {
@IsIn(ALLOWED_DIALPLAN_APPLICATIONS) @IsIn(ALLOWED_DIALPLAN_APPLICATIONS)
@@ -8,5 +9,6 @@ export class ActionDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(500) @MaxLength(500)
@IsSafeDialplanData({ message: "data usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
data?: string; data?: string;
} }

View File

@@ -4,15 +4,14 @@ import {
ArrayMinSize, ArrayMinSize,
IsArray, IsArray,
IsBoolean, IsBoolean,
IsIn,
IsInt, IsInt,
IsOptional, IsOptional,
IsString, IsString,
MaxLength, MaxLength,
ValidateNested, ValidateNested,
} from "class-validator"; } from "class-validator";
import { ALLOWED_CONDITION_FIELDS, type AllowedConditionField } from "@b2bcall/telephony";
import { ActionDto } from "./action.dto"; import { ActionDto } from "./action.dto";
import { IsAllowedConditionField } from "./is-allowed-condition-field.validator";
export class CreateDialplanExtensionDto { export class CreateDialplanExtensionDto {
@IsOptional() @IsOptional()
@@ -24,8 +23,10 @@ export class CreateDialplanExtensionDto {
@MaxLength(120) @MaxLength(120)
name!: string; name!: string;
@IsIn(ALLOWED_CONDITION_FIELDS) @IsString()
conditionField!: AllowedConditionField; @MaxLength(80)
@IsAllowedConditionField()
conditionField!: string;
@IsString() @IsString()
@MaxLength(255) @MaxLength(255)

View File

@@ -0,0 +1,29 @@
import { registerDecorator, type ValidationOptions } from "class-validator";
import { isAllowedConditionField } from "@b2bcall/telephony";
/**
* PHASE 56 (IVR): `conditionField` era um `@IsIn` fechado (6 valores
* fixos). Continua aceitando esses, mas agora também `${variavel}`
* simples (ex.: `${ivr_choice}`) — necessário pra ramificar um menu de
* IVR pelo dígito que `play_and_get_digits` coletou. Delegado pra
* `isAllowedConditionField` (packages/telephony) pra manter as duas
* pontas (o que a API aceita e o que o XML de fato usa) sincronizadas.
*/
export function IsAllowedConditionField(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: "isAllowedConditionField",
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown) {
return typeof value === "string" && isAllowedConditionField(value);
},
defaultMessage() {
return "conditionField deve ser um dos campos fixos ou uma variável simples como ${ivr_choice}";
},
},
});
};
}

View File

@@ -0,0 +1,31 @@
import { registerDecorator, type ValidationOptions, type ValidationArguments } from "class-validator";
import { findDisallowedInlineFunctionCalls } from "@b2bcall/telephony";
/**
* Bloqueia `${nome(args)}` que não esteja no allowlist de APIs seguras
* (`ALLOWED_INLINE_API_FUNCTIONS`) — achado real de segurança (secao 180):
* o allowlist de `application` (set/export/playback/...) não impedia RCE
* via `${system(...)}`/`${bg_system(...)}` embutido no `data` de uma
* application já permitida (`mod_commands` está carregado no FreeSWITCH
* desta implantação). `${variavel}` sem parênteses nunca é bloqueado.
*/
export function IsSafeDialplanData(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: "isSafeDialplanData",
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown) {
if (typeof value !== "string") return true; // @IsString() já cobre o tipo
return findDisallowedInlineFunctionCalls(value).length === 0;
},
defaultMessage(args: ValidationArguments) {
const disallowed = findDisallowedInlineFunctionCalls(String(args.value));
return `data usa função não permitida: ${disallowed.join(", ")} (nunca system/bg_system/curl/db/lua/shell — RCE no FreeSWITCH)`;
},
},
});
};
}

View File

@@ -33,4 +33,37 @@ export class CreateExtensionDto {
@Min(1) @Min(1)
@Max(10) @Max(10)
maxRegistrations?: number; maxRegistrations?: number;
/** Grupo de captura (secao 178) — ramais no mesmo grupo podem capturar
* a chamada um do outro (*8 no dialplan); ramais fora do grupo, não.
* Sem grupo (null) = ninguém mais captura essa chamada. */
@IsOptional()
@IsString()
@MaxLength(60)
@Matches(/^[a-zA-Z0-9_-]+$/, { message: "callGroup só pode ter letras, números, hífen e underscore" })
callGroup?: string;
}
export class UpdateExtensionDto {
@IsOptional()
@IsString()
@MaxLength(80)
callerIdName?: string;
@IsOptional()
@IsString()
@Matches(/^[0-9]{2,20}$/)
callerIdNumber?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
maxRegistrations?: number;
@IsOptional()
@IsString()
@MaxLength(60)
@Matches(/^[a-zA-Z0-9_-]+$/, { message: "callGroup só pode ter letras, números, hífen e underscore" })
callGroup?: string;
} }

View File

@@ -8,18 +8,19 @@ import {
HttpStatus, HttpStatus,
NotFoundException, NotFoundException,
Param, Param,
Patch,
Post, Post,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { generateStrongPassword, encryptSecret } from "@b2bcall/shared"; import { generateStrongPassword, encryptSecret, decryptSecret } from "@b2bcall/shared";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { assertQuota } from "@b2bcall/entitlements"; import { assertQuota } from "@b2bcall/entitlements";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard"; import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard"; import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator"; import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateExtensionDto } from "./dto/create-extension.dto"; import { CreateExtensionDto, UpdateExtensionDto } from "./dto/create-extension.dto";
function toPublicExtension(ext: { function toPublicExtension(ext: {
id: string; id: string;
@@ -32,7 +33,9 @@ function toPublicExtension(ext: {
context: string; context: string;
sofiaProfile: string; sofiaProfile: string;
codecs: string; codecs: string;
callGroup: string | null;
maxRegistrations: number; maxRegistrations: number;
registeredAt: Date | null;
enabled: boolean; enabled: boolean;
createdAt: Date; createdAt: Date;
}) { }) {
@@ -81,6 +84,7 @@ export class ExtensionsController {
context: dto.context ?? "default", context: dto.context ?? "default",
sofiaProfile: dto.sofiaProfile ?? "internal", sofiaProfile: dto.sofiaProfile ?? "internal",
maxRegistrations: dto.maxRegistrations ?? 1, maxRegistrations: dto.maxRegistrations ?? 1,
callGroup: dto.callGroup,
}, },
}), }),
); );
@@ -126,6 +130,108 @@ export class ExtensionsController {
return toPublicExtension(extension); return toPublicExtension(extension);
} }
/**
* Redefine a senha SIP (agente.md secao 39: nunca reexpor a senha
* existente — a única forma de "editar" é gerar uma nova e mostrar
* ela UMA vez, mesmo caminho da criação). `b2bcall-fs-config` resolve
* o directory ao vivo por request (sem arquivo/sync intermediário,
* diferente de trunks/queues) — um `UPDATE` aqui já é o suficiente,
* o próximo REGISTER do ramal usa a senha nova automaticamente.
*/
@RequirePermission("extensions.manage")
@Post(":id/reset-password")
async resetPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const plainPassword = generateStrongPassword();
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.updateMany({
where: { id, tenantId, deletedAt: null },
data: { sipPasswordEnc: encryptSecret(plainPassword) },
}),
);
if (result.count === 0) {
throw new NotFoundException();
}
await recordAuditEvent(prisma, {
action: "EXTENSION_RESET_PASSWORD",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
});
return { sipPassword: plainPassword };
}
/**
* Revela a senha SIP atual (achado real reportado pelo usuário: "show
* once" puro não funciona no dia a dia — reconfigurar um telefone físico
* ou um softphone precisa da senha de novo, e forçar reset toda vez
* derruba o registro de qualquer aparelho já configurado com a senha
* antiga). Diferente de `resetPassword`: não gera senha nova, só
* decifra a que já existe (`sipPasswordEnc` é criptografia reversível
* AES-256-GCM, não hash — sempre foi possível decifrar, só não estava
* exposto). Cada chamada fica no audit log — ver a senha de novo é uma
* ação sensível, mesmo sem trocar nada.
*/
@RequirePermission("extensions.manage")
@Post(":id/reveal-password")
async revealPassword(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const extension = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.findFirst({ where: { id, tenantId, deletedAt: null } }),
);
if (!extension) throw new NotFoundException();
await recordAuditEvent(prisma, {
action: "EXTENSION_PASSWORD_REVEALED",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
});
return { sipPassword: decryptSecret(extension.sipPasswordEnc) };
}
@RequirePermission("extensions.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateExtensionDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.updateMany({
where: { id, tenantId, deletedAt: null },
data: {
...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}),
...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}),
...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}),
...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}),
},
}),
);
if (result.count === 0) throw new NotFoundException();
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.extension.findFirstOrThrow({ where: { id } }));
await recordAuditEvent(prisma, {
action: "EXTENSION_UPDATE",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
after: { ...dto },
});
return toPublicExtension(updated);
}
@RequirePermission("extensions.manage") @RequirePermission("extensions.manage")
@Delete(":id") @Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -0,0 +1,68 @@
import { IsBoolean, IsIn, IsOptional, IsString, Matches, MaxLength } from "class-validator";
const DESTINATION_TYPES = ["EXTENSION", "IVR", "QUEUE", "CALL_GROUP"] as const;
export class CreateInboundRouteDto {
// Numero como o provedor de troncos manda no INVITE (destination_number) —
// normalmente so digitos (E.164 sem "+" ou o formato local do provedor).
// Unico entre TODOS os tenants (ver InboundRoute no schema): dois tenants
// nunca podem reivindicar o mesmo DID.
@IsString()
@Matches(/^[0-9]{2,20}$/, { message: "didNumber deve ter só dígitos (2 a 20)" })
didNumber!: string;
@IsOptional()
@IsString()
@MaxLength(255)
description?: string;
// PHASE 62 — decide como `destinationNumber`/`destinationContext` são
// interpretados na resolução de entrada (ver dialplan-xml.ts):
// EXTENSION/IVR continuam discando pelo dialplan do tenant; QUEUE/
// CALL_GROUP são resolvidos direto, sem tocar no dialplan.
@IsOptional()
@IsIn(DESTINATION_TYPES)
destinationType?: (typeof DESTINATION_TYPES)[number];
// Contexto de dialplan do PRÓPRIO tenant que recebe a chamada depois da
// resolução — só usado quando destinationType é EXTENSION/IVR.
@IsOptional()
@IsString()
@MaxLength(80)
destinationContext?: string;
// Interpretação depende de destinationType: número de ramal (EXTENSION),
// IVR_ENTRY_DESTINATION fixo (IVR), Queue.id (QUEUE), ou
// Extension.callGroup (CALL_GROUP).
@IsString()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
destinationNumber!: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class UpdateInboundRouteDto {
@IsOptional()
@IsString()
@MaxLength(255)
description?: string;
@IsOptional()
@IsIn(DESTINATION_TYPES)
destinationType?: (typeof DESTINATION_TYPES)[number];
@IsOptional()
@IsString()
@MaxLength(80)
destinationContext?: string;
@IsOptional()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
destinationNumber?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}

View File

@@ -0,0 +1,155 @@
import {
Body,
ConflictException,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateInboundRouteDto, UpdateInboundRouteDto } from "./dto/create-inbound-route.dto";
/**
* Rotas de entrada por DID (PHASE 56, docs/INBOUND_ROUTES.md) — achado
* real: nenhuma chamada de tronco carregava `b2bcall_tenant_id` até aqui,
* então uma chamada de entrada não tinha como saber de qual tenant é.
* `didNumber` é @unique GLOBAL de propósito (mesma exceção já aceita em
* `Tenant.telephonyDomain`) — por isso o conflito de duplicidade só
* aparece no INSERT (a constraint do banco), nunca por uma pré-checagem
* cross-tenant: `InboundRoute` tem RLS de verdade (FORCE ROW LEVEL
* SECURITY), então uma query sem contexto de tenant não veria a linha de
* outro tenant mesmo se tentasse.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("inbound-routes")
export class InboundRoutesController {
@RequirePermission("inbound_routes.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateInboundRouteDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
try {
const route = await withTenantContext(prisma, tenantId, (tx) =>
tx.inboundRoute.create({
data: {
tenantId,
didNumber: dto.didNumber,
description: dto.description,
destinationType: dto.destinationType ?? "EXTENSION",
destinationContext: dto.destinationContext ?? "default",
destinationNumber: dto.destinationNumber,
enabled: dto.enabled ?? true,
},
}),
);
await recordAuditEvent(prisma, {
action: "INBOUND_ROUTE_CREATE",
tenantId,
userId: user.sub,
entityType: "inbound_route",
entityId: route.id,
after: { didNumber: route.didNumber, destinationContext: route.destinationContext, destinationNumber: route.destinationNumber },
});
return route;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
throw new ConflictException("Este número (DID) já está em uso por outra rota de entrada");
}
throw err;
}
}
@RequirePermission("inbound_routes.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
return withTenantContext(prisma, tenantId, (tx) =>
tx.inboundRoute.findMany({ where: { deletedAt: null }, orderBy: { didNumber: "asc" } }),
);
}
@RequirePermission("inbound_routes.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const route = await withTenantContext(prisma, tenantId, (tx) =>
tx.inboundRoute.findFirst({ where: { id, deletedAt: null } }),
);
if (!route) throw new NotFoundException();
return route;
}
@RequirePermission("inbound_routes.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateInboundRouteDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.inboundRoute.updateMany({
where: { id, tenantId, deletedAt: null },
data: {
...(dto.description !== undefined ? { description: dto.description } : {}),
...(dto.destinationType !== undefined ? { destinationType: dto.destinationType } : {}),
...(dto.destinationContext !== undefined ? { destinationContext: dto.destinationContext } : {}),
...(dto.destinationNumber !== undefined ? { destinationNumber: dto.destinationNumber } : {}),
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),
},
}),
);
if (result.count === 0) throw new NotFoundException();
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.inboundRoute.findFirstOrThrow({ where: { id } }));
await recordAuditEvent(prisma, {
action: "INBOUND_ROUTE_UPDATE",
tenantId,
userId: user.sub,
entityType: "inbound_route",
entityId: id,
after: { ...dto },
});
return updated;
}
@RequirePermission("inbound_routes.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.inboundRoute.updateMany({
where: { id, deletedAt: null },
data: { deletedAt: new Date(), enabled: false },
}),
);
if (result.count === 0) throw new NotFoundException();
await recordAuditEvent(prisma, {
action: "INBOUND_ROUTE_DELETE",
tenantId,
userId: user.sub,
entityType: "inbound_route",
entityId: id,
});
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { InboundRoutesController } from "./inbound-routes.controller";
@Module({
controllers: [InboundRoutesController],
})
export class InboundRoutesModule {}

View File

@@ -0,0 +1,87 @@
import { Type } from "class-transformer";
import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
export class IvrMenuOptionDto {
@IsIn(ALLOWED_IVR_DIGITS)
digit!: string;
@IsString()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
destinationNumber!: string;
@IsOptional()
@IsString()
@MaxLength(80)
destinationContext?: string;
@IsOptional()
@IsString()
@MaxLength(80)
label?: string;
// Posição do nó no editor visual (PHASE 61) — puramente de
// apresentação, nunca afeta o dialplan compilado.
@IsOptional()
@IsNumber()
positionX?: number;
@IsOptional()
@IsNumber()
positionY?: number;
}
export class CreateIvrMenuDto {
@IsString()
@MaxLength(80)
name!: string;
// Vira o `context` do dialplan compilado — derivado do nome no
// frontend (slug), mas validado aqui como qualquer outro context.
@IsString()
@Matches(/^[a-z0-9-]{1,60}$/, { message: "context deve ser minúsculo, com letras/números/hífen (1 a 60 caracteres)" })
context!: string;
@IsOptional()
@IsString()
@MaxLength(500)
@IsSafeDialplanData({ message: "greeting usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
greeting?: string;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(12)
@ValidateNested({ each: true })
@Type(() => IvrMenuOptionDto)
options!: IvrMenuOptionDto[];
}
export class UpdateIvrMenuDto {
@IsOptional()
@IsString()
@MaxLength(80)
name?: string;
@IsOptional()
@IsString()
@MaxLength(500)
@IsSafeDialplanData({ message: "greeting usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
greeting?: string;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(12)
@ValidateNested({ each: true })
@Type(() => IvrMenuOptionDto)
options?: IvrMenuOptionDto[];
@IsOptional()
@IsNumber()
entryPositionX?: number;
@IsOptional()
@IsNumber()
entryPositionY?: number;
}

View File

@@ -0,0 +1,416 @@
import {
BadRequestException,
Body,
ConflictException,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import { createReadStream } from "node:fs";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyReply, FastifyRequest } from "fastify";
import { XMLValidator } from "fast-xml-parser";
import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { buildDialplanXml, buildIvrDialplanExtensions, type IvrMenuOptionInput } from "@b2bcall/telephony";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateIvrMenuDto, UpdateIvrMenuDto } from "./dto/create-ivr-menu.dto";
// Path do HOST (apps/api roda fora do Docker) — o mesmo diretório
// aparece como /ivr-prompts dentro do container freeswitch (ver
// docker-compose.yml). `IvrMenu.greeting` grava o path como o
// FreeSWITCH enxerga (container), nunca o path do host.
const IVR_PROMPTS_HOST_ROOT = process.env.IVR_PROMPTS_HOST_ROOT ?? "/opt/b2bcall/data/ivr-prompts";
const IVR_PROMPTS_CONTAINER_ROOT = "/ivr-prompts";
function isValidWavHeader(buf: Buffer): boolean {
// RIFF....WAVE — cabeçalho mínimo, suficiente pra rejeitar qualquer
// coisa que não seja WAV antes de gravar no disco compartilhado com o
// FreeSWITCH (mod_sndfile está carregado e resample sozinho qualquer
// sample rate/canais válidos; não há mod_shout nesta implantação, então
// MP3 nunca funcionaria — melhor rejeitar cedo com mensagem clara).
return buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WAVE";
}
function promptHostPath(tenantId: string, menuId: string): string {
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`);
}
/**
* Tela de autoria de IVR (PHASE 58, docs/INBOUND_ROUTES.md) — por cima do
* editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
* recompila e reativa uma versão nova do contexto correspondente, o
* mesmo fluxo generate+activate que o editor manual faz, só que
* automático. Uma `InboundRoute` aponta pra cá com
* `destinationContext = IvrMenu.context` e
* `destinationNumber = IVR_ENTRY_DESTINATION` ("ivr_entry").
*/
async function compileAndActivateIvrDialplan(
prisma: ReturnType<typeof getPrismaClient>,
tenantId: string,
userId: string,
menu: { context: string; greeting: string | null },
options: IvrMenuOptionInput[],
): Promise<void> {
// Substitui as linhas compiladas anteriores desse contexto — nunca
// acumula lixo de compilações antigas (mesmo padrão de "editar" já
// usado em reset-password/reveal-password: nunca reexpor/reaproveitar
// o estado velho, sempre um recorte limpo do estado atual).
await withTenantContext(prisma, tenantId, (tx) =>
tx.dialplanExtension.updateMany({
where: { tenantId, context: menu.context, deletedAt: null },
data: { deletedAt: new Date(), enabled: false },
}),
);
const compiled = buildIvrDialplanExtensions(menu, options);
await withTenantContext(prisma, tenantId, async (tx) => {
for (const ext of compiled) {
await tx.dialplanExtension.create({
data: {
tenantId,
context: menu.context,
name: ext.name,
conditionField: ext.conditionField,
conditionExpr: ext.conditionExpr,
actions: ext.actions as unknown as Prisma.InputJsonValue,
continueOnFalse: ext.continueOnFalse,
order: ext.order,
},
});
}
});
const xml = buildDialplanXml(menu.context, compiled);
const validation = XMLValidator.validate(xml);
if (validation !== true) {
throw new BadRequestException(`XML gerado invalido: ${validation.err.msg}`);
}
const last = await withTenantContext(prisma, tenantId, (tx) =>
tx.dialplanVersion.findFirst({ where: { tenantId, context: menu.context }, orderBy: { version: "desc" } }),
);
const nextVersion = (last?.version ?? 0) + 1;
await withTenantContext(prisma, tenantId, async (tx) => {
await tx.dialplanVersion.updateMany({
where: { tenantId, context: menu.context, status: "ACTIVE" },
data: { status: "SUPERSEDED" },
});
await tx.dialplanVersion.create({
data: {
tenantId,
context: menu.context,
version: nextVersion,
generatedXml: xml,
status: "ACTIVE",
createdByUserId: userId,
activatedAt: new Date(),
},
});
});
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("ivr-menus")
export class IvrMenusController {
@RequirePermission("ivr.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateIvrMenuDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
let menu;
try {
menu = await withTenantContext(prisma, tenantId, async (tx) => {
const created = await tx.ivrMenu.create({
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
});
await tx.ivrMenuOption.createMany({
data: dto.options.map((opt) => ({
tenantId,
ivrMenuId: created.id,
digit: opt.digit,
destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default",
label: opt.label,
positionX: opt.positionX,
positionY: opt.positionY,
})),
});
return created;
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
throw new ConflictException("Já existe um menu de IVR com esse contexto neste tenant");
}
throw err;
}
const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({
digit: o.digit,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext ?? "default",
}));
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, createOptions);
await recordAuditEvent(prisma, {
action: "IVR_MENU_CREATE",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: menu.id,
after: { name: menu.name, context: menu.context, optionCount: dto.options.length },
});
return this.get(user, menu.id);
}
@RequirePermission("ivr.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
return withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.findMany({
where: { deletedAt: null },
include: { options: { orderBy: { digit: "asc" } } },
orderBy: { name: "asc" },
}),
);
}
@RequirePermission("ivr.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.findFirst({
where: { id, deletedAt: null },
include: { options: { orderBy: { digit: "asc" } } },
}),
);
if (!menu) throw new NotFoundException();
return menu;
}
@RequirePermission("ivr.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateIvrMenuDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const existing = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
);
if (!existing) throw new NotFoundException();
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
const updated = await tx.ivrMenu.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.greeting !== undefined ? { greeting: dto.greeting } : {}),
...(dto.entryPositionX !== undefined ? { entryPositionX: dto.entryPositionX } : {}),
...(dto.entryPositionY !== undefined ? { entryPositionY: dto.entryPositionY } : {}),
},
});
if (dto.options) {
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
await tx.ivrMenuOption.createMany({
data: dto.options.map((opt) => ({
tenantId,
ivrMenuId: id,
digit: opt.digit,
destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default",
label: opt.label,
positionX: opt.positionX,
positionY: opt.positionY,
})),
});
}
return updated;
});
const options: IvrMenuOptionInput[] = dto.options
? dto.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext ?? "default" }))
: existing.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext }));
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, options);
await recordAuditEvent(prisma, {
action: "IVR_MENU_UPDATE",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
after: { name: menu.name, greeting: menu.greeting, optionCount: options.length },
});
return this.get(user, id);
}
@RequirePermission("ivr.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.findFirst({ where: { id, deletedAt: null } }));
if (!menu) throw new NotFoundException();
await withTenantContext(prisma, tenantId, async (tx) => {
await tx.ivrMenu.update({ where: { id }, data: { deletedAt: new Date(), enabled: false } });
await tx.dialplanExtension.updateMany({
where: { tenantId, context: menu.context, deletedAt: null },
data: { deletedAt: new Date(), enabled: false },
});
});
if (menu.greeting?.startsWith(IVR_PROMPTS_CONTAINER_ROOT)) {
try {
await unlink(promptHostPath(tenantId, id));
} catch {
// arquivo já não existia — sem problema, o menu já foi apagado.
}
}
await recordAuditEvent(prisma, {
action: "IVR_MENU_DELETE",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
});
}
/**
* Upload do prompt de áudio (PHASE 59, docs/INBOUND_ROUTES.md) —
* único endpoint desta API que recebe um arquivo binário. Grava no
* disco compartilhado com o FreeSWITCH (nunca no object storage de
* gravações — aquele é lido por humanos depois da chamada via proxy
* autenticado; este precisa ser lido pelo PRÓPRIO FreeSWITCH ao vivo
* durante `play_and_get_digits`, então tem que ser um arquivo local
* de verdade, não uma URL de rede) e recompila o dialplan do menu com
* o novo `greeting` apontando pro path que o FreeSWITCH enxerga.
*/
@RequirePermission("ivr.manage")
@Post(":id/prompt")
async uploadPrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Req() request: FastifyRequest) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
);
if (!menu) throw new NotFoundException();
const data = await request.file();
if (!data) throw new BadRequestException("Nenhum arquivo enviado");
const buffer = await data.toBuffer();
if (!isValidWavHeader(buffer)) {
throw new BadRequestException(
"Arquivo não é um WAV válido — só WAV é aceito (esta implantação do FreeSWITCH não tem suporte a MP3)",
);
}
const hostPath = promptHostPath(tenantId, id);
await mkdir(join(IVR_PROMPTS_HOST_ROOT, tenantId), { recursive: true });
await writeFile(hostPath, buffer);
const containerPath = `${IVR_PROMPTS_CONTAINER_ROOT}/${tenantId}/${id}.wav`;
const updated = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.update({ where: { id }, data: { greeting: containerPath } }),
);
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, updated, options);
await recordAuditEvent(prisma, {
action: "IVR_MENU_PROMPT_UPLOAD",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
after: { filename: data.filename, sizeBytes: buffer.length },
});
return this.get(user, id);
}
@RequirePermission("ivr.manage")
@Delete(":id/prompt")
async removePrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
);
if (!menu) throw new NotFoundException();
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.update({ where: { id }, data: { greeting: null } }));
try {
await unlink(promptHostPath(tenantId, id));
} catch {
// arquivo já não existia — nada a fazer, greeting já voltou a null.
}
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, updated, options);
await recordAuditEvent(prisma, {
action: "IVR_MENU_PROMPT_DELETE",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
});
return this.get(user, id);
}
/** Preview autenticado do prompt — mesmo princípio do player de
* gravações (nunca uma URL direta pro storage/disco). */
@RequirePermission("ivr.view")
@Get(":id/prompt")
async downloadPrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Res() reply: FastifyReply) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.findFirst({ where: { id, deletedAt: null } }));
if (!menu || !menu.greeting?.startsWith(IVR_PROMPTS_CONTAINER_ROOT)) {
throw new NotFoundException();
}
reply.header("Content-Type", "audio/wav");
reply.header("Content-Disposition", "inline");
reply.send(createReadStream(promptHostPath(tenantId, id)));
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { IvrMenusController } from "./ivr-menus.controller";
@Module({
controllers: [IvrMenusController],
})
export class IvrModule {}

View File

@@ -0,0 +1,76 @@
import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Patch, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { UpdateCallbackDto } from "./dto/update-callback.dto";
/**
* "Discador > Callbacks" (agente.md secao 169) — visão tenant-wide (todas
* as campanhas) dos leads que o `PredictiveDialerEngine` marcou como
* `CALLBACK` (secao 78-79: agente pediu pra ligar de volta em outro
* horário). Antes desta tela não existia forma nenhuma de ver ou agir
* sobre esses leads — só ficavam esperando `nextAttemptAt` chegar, sem
* ninguém poder adiantar, adiar ou cancelar.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("leads/callbacks")
export class CallbacksController {
@RequirePermission("campaigns.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
return withTenantContext(prisma, tenantId, (tx) =>
tx.lead.findMany({
where: { tenantId, status: "CALLBACK" },
include: { campaign: { select: { id: true, name: true } } },
orderBy: { nextAttemptAt: "asc" },
take: 500,
}),
);
}
@RequirePermission("campaigns.update")
@Patch(":id")
async update(
@CurrentUser() user: AccessTokenClaims,
@Param("id") id: string,
@Body() dto: UpdateCallbackDto,
): Promise<Record<string, unknown>> {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const lead = await withTenantContext(prisma, tenantId, (tx) =>
tx.lead.findFirst({ where: { id, tenantId, status: "CALLBACK" } }),
);
if (!lead) throw new NotFoundException("Callback nao encontrado (ou o lead ja saiu do status CALLBACK)");
if (dto.action === "RESCHEDULE" && new Date(dto.nextAttemptAt!).getTime() <= Date.now()) {
throw new BadRequestException("nextAttemptAt precisa ser no futuro");
}
const data =
dto.action === "RESCHEDULE"
? { nextAttemptAt: new Date(dto.nextAttemptAt!) }
: dto.action === "REQUEUE"
? { status: "READY" as const, nextAttemptAt: null }
: { status: "DO_NOT_CALL" as const, nextAttemptAt: null };
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.lead.update({ where: { id }, data }));
await recordAuditEvent(prisma, {
action: "LEAD_CALLBACK_UPDATE",
tenantId,
userId: user.sub,
entityType: "lead",
entityId: id,
after: { action: dto.action, nextAttemptAt: dto.nextAttemptAt ?? null },
});
return updated;
}
}

View File

@@ -0,0 +1,12 @@
import { IsIn, IsISO8601, ValidateIf } from "class-validator";
const CALLBACK_ACTIONS = ["RESCHEDULE", "REQUEUE", "CANCEL"];
export class UpdateCallbackDto {
@IsIn(CALLBACK_ACTIONS)
action!: "RESCHEDULE" | "REQUEUE" | "CANCEL";
@ValidateIf((dto) => dto.action === "RESCHEDULE")
@IsISO8601()
nextAttemptAt?: string;
}

View File

@@ -1,7 +1,8 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { LeadsController } from "./leads.controller"; import { LeadsController } from "./leads.controller";
import { CallbacksController } from "./callbacks.controller";
@Module({ @Module({
controllers: [LeadsController], controllers: [LeadsController, CallbacksController],
}) })
export class LeadsModule {} export class LeadsModule {}

View File

@@ -6,11 +6,14 @@ import { ValidationPipe } from "@nestjs/common";
import helmet from "@fastify/helmet"; import helmet from "@fastify/helmet";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit"; import rateLimit from "@fastify/rate-limit";
import multipart from "@fastify/multipart";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter"; import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
import { runRetentionSweep } from "./recordings/retention-sweep"; import { runRetentionSweep } from "./recordings/retention-sweep";
import { runActiveDaySweep } from "./billing/active-day-sweep";
const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000; const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
const ACTIVE_DAY_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
@@ -36,6 +39,14 @@ async function bootstrap() {
timeWindow: "1 minute", timeWindow: "1 minute",
}); });
// Upload de prompt de áudio do IVR (PHASE 59) — único endpoint desta
// API que recebe um arquivo binário; limite de tamanho aqui evita um
// upload gigante travar o processo (ver ivr-menus.controller.ts pra
// validação de formato/conteúdo).
await app.register(multipart, {
limits: { fileSize: 8 * 1024 * 1024 },
});
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
whitelist: true, whitelist: true,
@@ -63,6 +74,13 @@ async function bootstrap() {
setInterval(() => { setInterval(() => {
runRetentionSweep().catch((err) => console.error("falha na varredura de retencao", err)); runRetentionSweep().catch((err) => console.error("falha na varredura de retencao", err));
}, RETENTION_SWEEP_INTERVAL_MS); }, RETENTION_SWEEP_INTERVAL_MS);
// Usage metering diario (agente.md secao 131: EXTENSION/AGENT/TRUNK
// ACTIVE_DAY) — mesmo padrao da varredura de retencao acima.
runActiveDaySweep().catch((err) => console.error("falha na varredura de uso diario (boot)", err));
setInterval(() => {
runActiveDaySweep().catch((err) => console.error("falha na varredura de uso diario", err));
}, ACTIVE_DAY_SWEEP_INTERVAL_MS);
} }
bootstrap(); bootstrap();

View File

@@ -60,7 +60,12 @@ export class PauseReasonsController {
async list(@CurrentUser() user: AccessTokenClaims) { async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
const tenantId = user.tenantId!; const tenantId = user.tenantId!;
return withTenantContext(prisma, tenantId, (tx) => tx.pauseReason.findMany({ orderBy: { name: "asc" } })); // Bug real, achado testando o frontend: sem o filtro `enabled: true`
// aqui, um motivo "removido" (soft delete, ver `remove` abaixo) nunca
// sumia da lista — único endpoint deste arquivo sem esse filtro.
return withTenantContext(prisma, tenantId, (tx) =>
tx.pauseReason.findMany({ where: { enabled: true }, orderBy: { name: "asc" } }),
);
} }
@RequirePermission("agents.manage") @RequirePermission("agents.manage")

View File

@@ -0,0 +1,169 @@
import { IsBoolean, IsInt, IsOptional, IsString, Matches, MaxLength, Min } from "class-validator";
export class CreatePlanDto {
@IsString()
@MaxLength(40)
@Matches(/^[a-z0-9_-]+$/, { message: "key deve ser minusculo, so letras/numeros/hifen/underscore" })
key!: string;
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsInt()
@Min(0)
maxExtensions?: number;
@IsOptional()
@IsInt()
@Min(0)
maxAgents?: number;
@IsOptional()
@IsInt()
@Min(0)
maxTrunks?: number;
@IsOptional()
@IsInt()
@Min(0)
maxQueues?: number;
@IsOptional()
@IsInt()
@Min(0)
maxCampaigns?: number;
@IsOptional()
@IsInt()
@Min(0)
maxCps?: number;
@IsOptional()
@IsInt()
@Min(0)
maxConcurrentCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxDailyCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxMonthlyCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxRecordingStorageGb?: number;
@IsOptional()
@IsInt()
@Min(0)
recordingRetentionDays?: number;
@IsOptional()
@IsInt()
@Min(0)
transcriptionRetentionDays?: number;
@IsOptional()
@IsBoolean()
recordingEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiTranscriptionEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiAnalysisEnabled?: boolean;
@IsOptional()
@IsBoolean()
apiAccessEnabled?: boolean;
}
export class UpdatePlanDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsInt()
@Min(0)
maxExtensions?: number;
@IsOptional()
@IsInt()
@Min(0)
maxAgents?: number;
@IsOptional()
@IsInt()
@Min(0)
maxTrunks?: number;
@IsOptional()
@IsInt()
@Min(0)
maxQueues?: number;
@IsOptional()
@IsInt()
@Min(0)
maxCampaigns?: number;
@IsOptional()
@IsInt()
@Min(0)
maxCps?: number;
@IsOptional()
@IsInt()
@Min(0)
maxConcurrentCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxDailyCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxMonthlyCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxRecordingStorageGb?: number;
@IsOptional()
@IsBoolean()
recordingEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiTranscriptionEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiAnalysisEnabled?: boolean;
@IsOptional()
@IsBoolean()
apiAccessEnabled?: boolean;
}

View File

@@ -0,0 +1,87 @@
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreatePlanDto, UpdatePlanDto } from "./dto/create-plan.dto";
/**
* Catálogo de planos (agente.md secao 56, 126) — global, sem RLS (mesmo
* critério de `plans` desde a PHASE 14: "sem tenant_id, catálogo
* compartilhado"). Só platform admin gerencia; qualquer campo de limite
* `null` significa "sem limite" (nunca "sem plano" — `Tenant.planId` é
* obrigatório desde a migration que fez o backfill).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("plans")
export class PlansController {
@RequirePermission("pricing.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePlanDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode criar planos");
}
const prisma = getPrismaClient();
const existing = await prisma.plan.findUnique({ where: { key: dto.key } });
if (existing) {
throw new ForbiddenException(`Ja existe um plano com key "${dto.key}"`);
}
const plan = await prisma.plan.create({ data: dto });
await recordAuditEvent(prisma, {
action: "PLAN_CREATE",
tenantId: null,
userId: user.sub,
entityType: "plan",
entityId: plan.id,
after: { key: plan.key, name: plan.name },
});
return plan;
}
@RequirePermission("pricing.manage")
@Get()
async list() {
const prisma = getPrismaClient();
return prisma.plan.findMany({ orderBy: { name: "asc" } });
}
@RequirePermission("pricing.manage")
@Get(":id")
async get(@Param("id") id: string) {
const prisma = getPrismaClient();
const plan = await prisma.plan.findUnique({ where: { id } });
if (!plan) throw new NotFoundException();
return plan;
}
@RequirePermission("pricing.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdatePlanDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode editar planos");
}
const prisma = getPrismaClient();
const existing = await prisma.plan.findUnique({ where: { id } });
if (!existing) throw new NotFoundException();
const plan = await prisma.plan.update({ where: { id }, data: dto });
await recordAuditEvent(prisma, {
action: "PLAN_UPDATE",
tenantId: null,
userId: user.sub,
entityType: "plan",
entityId: plan.id,
after: { ...dto },
});
return plan;
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { PlansController } from "./plans.controller";
@Module({
controllers: [PlansController],
})
export class PlansModule {}

View File

@@ -0,0 +1,6 @@
import { IsUrl } from "class-validator";
export class UpdateWebrtcProxyDto {
@IsUrl({ protocols: ["ws", "wss"], require_protocol: true, require_tld: false })
url!: string;
}

View File

@@ -0,0 +1,76 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "IA > Uso/Custos" (agente.md secao 169) — visão de plataforma, todos os
* tenants, diferente do BYOK do tenant (`/ai/providers`, `/ai/models`,
* escopo TENANT/GLOBAL). Uso = quantidade bruta do ledger `AIUsageRecord`
* (mesmo ledger que `/reports/consumo` usa por tenant, aqui somado em
* todos). Custo = uso × preço unitário do `AIModel` correspondente
* (`providerId` + `model` batendo com `externalModelId`) — quando não dá
* pra casar um registro de uso com um AIModel cadastrado (ex.: provider
* apagado, nome de modelo mudou), o custo daquele registro fica de fora
* da soma e o tenant é marcado `costIncomplete: true`, nunca um número
* inventado (agente.md secao 138/233: null > estimativa disfarçada de
* número fechado).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/ai-usage")
export class PlatformAiUsageController {
@RequirePermission("tenants.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver uso de IA de todos os tenants");
}
const prisma = getPrismaClient();
const now = new Date();
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const tenants = await prisma.tenant.findMany({ where: { deletedAt: null }, orderBy: { legalName: "asc" } });
const models = await prisma.aIModel.findMany({ select: { providerId: true, externalModelId: true, inputCost: true, outputCost: true, audioCost: true } });
const modelByKey = new Map(models.map((m) => [`${m.providerId}:${m.externalModelId}`, m]));
return Promise.all(
tenants.map(async (tenant) => {
const records = await withTenantContext(prisma, tenant.id, (tx) =>
tx.aIUsageRecord.findMany({ where: { tenantId: tenant.id, occurredAt: { gte: monthStart } } }),
);
const usage = { transcriptionSeconds: 0, analysisRequests: 0, inputTokens: 0, outputTokens: 0 };
let cost = 0;
let costIncomplete = false;
for (const r of records) {
if (r.type === "AI_TRANSCRIPTION_SECONDS") usage.transcriptionSeconds += r.quantity;
if (r.type === "AI_ANALYSIS_REQUEST") usage.analysisRequests += r.quantity;
if (r.type === "AI_INPUT_TOKENS") usage.inputTokens += r.quantity;
if (r.type === "AI_OUTPUT_TOKENS") usage.outputTokens += r.quantity;
const model = r.providerId && r.model ? modelByKey.get(`${r.providerId}:${r.model}`) : undefined;
if (!model) {
if (r.type !== "AI_ANALYSIS_REQUEST") costIncomplete = true;
continue;
}
if (r.type === "AI_TRANSCRIPTION_SECONDS" && model.audioCost != null) cost += r.quantity * model.audioCost;
else if (r.type === "AI_INPUT_TOKENS" && model.inputCost != null) cost += r.quantity * model.inputCost;
else if (r.type === "AI_OUTPUT_TOKENS" && model.outputCost != null) cost += r.quantity * model.outputCost;
}
return {
tenantId: tenant.id,
legalName: tenant.legalName,
usage,
estimatedCost: records.length === 0 ? null : cost,
costIncomplete,
};
}),
);
}
}

View File

@@ -0,0 +1,56 @@
import { Controller, ForbiddenException, Get, Query, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "Sistema > Auditoria" (agente.md secao 150-151, 168) — `audit_logs` não
* tem RLS (linha imutável de auditoria, precisa sobreviver mesmo que o
* tenant seja apagado — decisão do schema desde a PHASE 04), então uma
* consulta cross-tenant direta é segura aqui; só platform admin acessa
* este endpoint (um tenant admin vê o próprio audit trail por outro
* caminho, se/quando existir).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/audit-log")
export class PlatformAuditController {
@RequirePermission("audit.view")
@Get()
async list(
@CurrentUser() user: AccessTokenClaims,
@Query("action") action?: string,
@Query("tenantId") tenantId?: string,
): Promise<Record<string, unknown>[]> {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver o audit log da plataforma");
}
const prisma = getPrismaClient();
const entries = await prisma.auditLog.findMany({
where: {
...(action ? { action: { contains: action, mode: "insensitive" } } : {}),
...(tenantId ? { tenantId } : {}),
},
orderBy: { createdAt: "desc" },
take: 200,
});
const userIds = Array.from(new Set(entries.map((e) => e.userId).filter((id): id is string => id != null)));
const tenantIds = Array.from(new Set(entries.map((e) => e.tenantId).filter((id): id is string => id != null)));
const [users, tenants] = await Promise.all([
userIds.length ? prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, email: true } }) : [],
tenantIds.length ? prisma.tenant.findMany({ where: { id: { in: tenantIds } }, select: { id: true, legalName: true } }) : [],
]);
const userEmail = new Map(users.map((u) => [u.id, u.email]));
const tenantName = new Map(tenants.map((t) => [t.id, t.legalName]));
return entries.map((e) => ({
...e,
userEmail: e.userId ? (userEmail.get(e.userId) ?? e.userId) : null,
tenantName: e.tenantId ? (tenantName.get(e.tenantId) ?? e.tenantId) : null,
}));
}
}

View File

@@ -0,0 +1,75 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* Infraestrutura > FreeSWITCH/SIP Profiles/Nodes (agente.md secao 168-169)
* — introspecção ESL real, mesmo padrão de conexão avulsa (connect →
* comando → disconnect) já usado em `PlatformHealthController`. Igual ao
* health check, nunca deixa a indisponibilidade do ESL virar 500/503 pro
* cliente — devolve `{ ok: false, error }` (mesma filosofia do `timed()`
* de lá), porque nesta VM o Event Socket (8021) é deliberadamente não
* publicado no host (agente.md secao 184) e `apps/api` roda fora do
* Docker: essas 3 telas SEMPRE vão mostrar essa mensagem aqui, mesmo
* endpoint funcionando corretamente em produção (mesma network do
* FreeSWITCH). "Nodes" é sempre 1 node nesta implantação (container
* único, sem clustering) — honesto em vez de fingir uma lista.
*/
async function withEsl<T>(fn: (provider: FreeSwitchTelephonyProvider) => Promise<T>): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
const host = process.env.ESL_HOST;
const port = Number(process.env.ESL_PORT ?? 8021);
const password = process.env.ESL_PASSWORD;
if (!host || !password) return { ok: false, error: "ESL nao configurado (ESL_HOST/ESL_PASSWORD ausentes)" };
const provider = new FreeSwitchTelephonyProvider({ host, port, password });
try {
provider.connect();
const connected = await provider.waitUntilConnected(2500);
if (!connected) return { ok: false, error: "Timeout conectando no ESL do FreeSWITCH" };
return { ok: true, data: await fn(provider) };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : "Erro desconhecido falando com o ESL" };
} finally {
await provider.disconnect();
}
}
function requirePlatform(user: AccessTokenClaims): Promise<boolean> {
return isPlatformUser(user.sub);
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/freeswitch")
export class PlatformFreeswitchController {
@RequirePermission("freeswitch.view")
@Get("channels")
async channels(@CurrentUser() user: AccessTokenClaims) {
if (!(await requirePlatform(user))) throw new ForbiddenException("So' um usuario com role de plataforma pode ver o FreeSWITCH");
return withEsl(async (p) => ({ channels: await p.getChannels(), calls: await p.getCalls() }));
}
@RequirePermission("freeswitch.view")
@Get("profiles")
async profiles(@CurrentUser() user: AccessTokenClaims) {
if (!(await requirePlatform(user))) throw new ForbiddenException("So' um usuario com role de plataforma pode ver SIP profiles");
return withEsl(async (p) => ({
sofiaStatus: await p.runApi("sofia status"),
registrations: await p.getRegistrations(),
}));
}
@RequirePermission("freeswitch.view")
@Get("nodes")
async nodes(@CurrentUser() user: AccessTokenClaims) {
if (!(await requirePlatform(user))) throw new ForbiddenException("So' um usuario com role de plataforma pode ver os nodes");
return withEsl(async (p) => ({
nodeCount: 1,
status: await p.runApi("status"),
gateways: await p.getGateways(),
}));
}
}

View File

@@ -0,0 +1,65 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { getRedisClient } from "../common/redis";
type CheckStatus = "ok" | "fail";
async function timed<T>(fn: () => Promise<T>): Promise<{ status: CheckStatus; latencyMs: number }> {
const start = Date.now();
try {
await fn();
return { status: "ok", latencyMs: Date.now() - start };
} catch {
return { status: "fail", latencyMs: Date.now() - start };
}
}
/**
* "Infraestrutura > Saúde" (agente.md secao 168, 187). `apps/api` não
* mantém uma conexão ESL permanente (isso é trabalho de fs-events/fs-
* config/predictive-dialer, secao 21) — pra não adicionar esse estado só
* pra um health check de tela de admin, abre uma conexão ESL avulsa,
* espera conectar, e derruba. Aceitável aqui: endpoint de baixo tráfego,
* chamado por um humano olhando a tela, não um hot path.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/health")
export class PlatformHealthController {
@RequirePermission("freeswitch.view")
@Get()
async check(@CurrentUser() user: AccessTokenClaims) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver a saude da infraestrutura");
}
const [postgres, redis, freeswitch] = await Promise.all([
timed(() => getPrismaClient().$queryRaw`SELECT 1`),
timed(async () => {
await getRedisClient().ping();
}),
timed(async () => {
const host = process.env.ESL_HOST;
const port = Number(process.env.ESL_PORT ?? 8021);
const password = process.env.ESL_PASSWORD;
if (!host || !password) throw new Error("ESL nao configurado");
const provider = new FreeSwitchTelephonyProvider({ host, port, password });
try {
provider.connect();
const connected = await provider.waitUntilConnected(2500);
if (!connected) throw new Error("timeout conectando no ESL");
} finally {
await provider.disconnect();
}
}),
]);
return { postgres, redis, freeswitch, checkedAt: new Date().toISOString() };
}
}

View File

@@ -0,0 +1,97 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "Dashboard Platform" (agente.md secao 163) — só platform admin (mesmo
* padrão de isPlatformUser já usado pra escrita GLOBAL em AIProvider/
* AIPromptTemplate). Consulta direto (sem withTenantContext — precisa
* agregar TODOS os tenants, não faz sentido sob RLS de um tenant só).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform")
export class PlatformOverviewController {
@RequirePermission("tenants.view")
@Get("overview")
async overview(@CurrentUser() user: AccessTokenClaims) {
const isPlatform = await isPlatformUser(user.sub);
if (!isPlatform) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver o dashboard da plataforma");
}
const prisma = getPrismaClient();
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const monthStart = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
const [tenantsActive, tenantsTotal, extensionsTotal, agentsTotal, callsCurrent, callsToday, cpsCapacity] =
await Promise.all([
prisma.tenant.count({ where: { status: "ACTIVE", deletedAt: null } }),
prisma.tenant.count({ where: { deletedAt: null } }),
prisma.extension.count({ where: { deletedAt: null } }),
prisma.agent.count({ where: { deletedAt: null } }),
prisma.call.count({ where: { endAt: null } }),
prisma.call.count({ where: { createdAt: { gte: todayStart } } }),
prisma.plan.aggregate({ _sum: { maxCps: true } }),
]);
// `ai_usage_records`/`recordings` têm FORCE RLS (secao 32) — as duas
// agregações acima (achado real, corrigido aqui) rodavam direto no
// Prisma sem `app.current_tenant_id` nenhum setado, então SEMPRE
// devolviam 0 linhas (zero silencioso, sem erro nenhum), nunca o
// número real, não importa quanto uso existisse nos tenants. Mesma
// classe de bug já corrigida em `TenantsController.list` — só um
// loop `withTenantContext` por tenant enxerga as linhas de verdade.
const activeTenantIds = await prisma.tenant.findMany({ where: { deletedAt: null }, select: { id: true } });
const [aiUsagePerTenant, recordingBytesPerTenant] = await Promise.all([
Promise.all(
activeTenantIds.map((t) =>
withTenantContext(prisma, t.id, (tx) =>
tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId: t.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
),
),
),
Promise.all(
activeTenantIds.map((t) =>
withTenantContext(prisma, t.id, (tx) => tx.recording.aggregate({ where: { tenantId: t.id }, _sum: { sizeBytes: true } })),
),
),
]);
const aiUsageThisMonth: Record<string, number> = {};
for (const rows of aiUsagePerTenant) {
for (const row of rows) aiUsageThisMonth[row.type] = (aiUsageThisMonth[row.type] ?? 0) + (row._sum.quantity ?? 0);
}
const recordingStorageBytes = recordingBytesPerTenant.reduce((sum, agg) => sum + Number(agg._sum.sizeBytes ?? 0n), 0);
return {
tenantsActive,
tenantsTotal,
extensionsTotal,
agentsTotal,
callsCurrent,
callsToday,
// Deployment desta lab tem 1 unico container FreeSWITCH — sem tabela
// de nodes ainda pra descobrir isso dinamicamente (nao existe
// clustering multi-node nesta fase).
freeswitchNodes: 1,
// Soma dos tetos de CPS configurados por plano em todos os tenants —
// capacidade OUTORGADA, nao consumo em tempo real (isso vive no
// token bucket do Redis do predictive-dialer, apps/api nao le de
// la ainda).
cpsCapacityConfigured: cpsCapacity._sum.maxCps ?? null,
aiUsageThisMonth,
recordingStorageBytes,
// Precisa de BillingPeriod/BillingStatement fechados de verdade
// (fase Billing, em construcao) — nenhum periodo foi fechado ainda
// nesta lab, entao nao ha numero real pra mostrar. null e' honesto,
// nao 0.
monthlyConsumption: null as number | null,
estimatedRevenue: null as number | null,
};
}
}

View File

@@ -0,0 +1,73 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "Clientes > Quotas" (agente.md secao 169) — uso vs. limite do plano em
* TODOS os tenants, pra platform admin achar quem está perto de estourar
* sem precisar abrir um por um. Mesmo cálculo de `/reports/consumo`
* (tenant), só que looping por tenant (RLS, secao 32) em vez de um só.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/quotas")
export class PlatformQuotasController {
@RequirePermission("tenants.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver quotas de todos os tenants");
}
const prisma = getPrismaClient();
const now = new Date();
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const tenants = await prisma.tenant.findMany({
where: { deletedAt: null },
include: { plan: true },
orderBy: { legalName: "asc" },
});
return Promise.all(
tenants.map(async (tenant) => {
const [callCountAgg, recordingAgg, extensionCount, agentCount, trunkCount, queueCount, campaignCount] =
await withTenantContext(prisma, tenant.id, (tx) =>
Promise.all([
tx.usageEvent.aggregate({ where: { tenantId: tenant.id, meter: "CALL_COUNT", occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
tx.recording.aggregate({ where: { tenantId: tenant.id }, _sum: { sizeBytes: true } }),
tx.extension.count({ where: { tenantId: tenant.id, deletedAt: null } }),
tx.agent.count({ where: { tenantId: tenant.id, deletedAt: null } }),
tx.trunk.count({ where: { tenantId: tenant.id, deletedAt: null } }),
tx.queue.count({ where: { tenantId: tenant.id, deletedAt: null } }),
tx.campaign.count({ where: { tenantId: tenant.id, deletedAt: null } }),
]),
);
const callCount = callCountAgg._sum.quantity ?? 0;
const recordingBytes = Number(recordingAgg._sum.sizeBytes ?? 0n);
const recordingGb = recordingBytes / 1024 ** 3;
const ratio = (used: number, max: number | null) => (max ? used / max : null);
return {
tenantId: tenant.id,
legalName: tenant.legalName,
planName: tenant.plan.name,
status: tenant.status,
items: [
{ key: "extensions", label: "Ramais", used: extensionCount, max: tenant.plan.maxExtensions, ratio: ratio(extensionCount, tenant.plan.maxExtensions) },
{ key: "agents", label: "Agentes", used: agentCount, max: tenant.plan.maxAgents, ratio: ratio(agentCount, tenant.plan.maxAgents) },
{ key: "trunks", label: "Troncos", used: trunkCount, max: tenant.plan.maxTrunks, ratio: ratio(trunkCount, tenant.plan.maxTrunks) },
{ key: "queues", label: "Filas", used: queueCount, max: tenant.plan.maxQueues, ratio: ratio(queueCount, tenant.plan.maxQueues) },
{ key: "campaigns", label: "Campanhas", used: campaignCount, max: tenant.plan.maxCampaigns, ratio: ratio(campaignCount, tenant.plan.maxCampaigns) },
{ key: "monthlyCalls", label: "Chamadas/mês", used: callCount, max: tenant.plan.maxMonthlyCalls, ratio: ratio(callCount, tenant.plan.maxMonthlyCalls) },
{ key: "recordingGb", label: "Armazenamento (GB)", used: Number(recordingGb.toFixed(3)), max: tenant.plan.maxRecordingStorageGb, ratio: ratio(recordingGb, tenant.plan.maxRecordingStorageGb) },
],
};
}),
);
}
}

View File

@@ -0,0 +1,46 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "Sistema > Permissões" (agente.md secao 142-145, 168) — só leitura por
* enquanto: RBAC é system-defined (roles/permissions vêm do seed,
* `packages/auth/src/seed.ts`), sem UI de criar role customizada ainda.
* `roles`/`permissions` não têm RLS (catálogo global do sistema).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/roles")
export class PlatformRolesController {
@RequirePermission("roles.manage")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver o catalogo de roles/permissoes");
}
const prisma = getPrismaClient();
const [roles, permissions] = await Promise.all([
prisma.role.findMany({
include: { rolePermissions: { include: { permission: true } } },
orderBy: { name: "asc" },
}),
prisma.permission.findMany({ orderBy: { key: "asc" } }),
]);
return {
permissions,
roles: roles.map((r) => ({
id: r.id,
key: r.key,
name: r.name,
scope: r.scope,
isSystem: r.isSystem,
permissionKeys: r.rolePermissions.map((rp) => rp.permission.key),
})),
};
}
}

View File

@@ -0,0 +1,45 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "Sistema > Configurações" (agente.md secao 169) — nunca teve escopo
* definido na especificação. Decisão desta implementação: painel
* read-only das flags de segurança/infra que já existem como variável de
* ambiente (`.env`, compartilhado por `apps/api` e os workers via
* systemd/docker-compose — ver `infrastructure/systemd/README.md` e
* `docker-compose.yml`), nunca editável por aqui — mudar exige editar o
* `.env` e reiniciar o serviço (documentado, não um botão de UI que
* fingiria aplicar na hora). Nunca expõe segredo nenhum (senha, chave,
* connection string) — só booleans/enums que já são público conhecimento
* de quem administra a infraestrutura.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/system-config")
export class PlatformSystemConfigController {
@RequirePermission("tenants.view")
@Get()
async get(@CurrentUser() user: AccessTokenClaims) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver a configuracao do sistema");
}
const dialerSimulation = (process.env.DIALER_SIMULATION ?? "true") === "true";
const allowRealOutboundCalls = (process.env.ALLOW_REAL_OUTBOUND_CALLS ?? "false") === "true";
return {
nodeEnv: process.env.NODE_ENV ?? "development",
dialerSimulation,
allowRealOutboundCalls,
// As DUAS precisam estar explicitamente ligadas (secao 186) — nunca
// basta uma pra originar PSTN de verdade.
realOutboundCallsActive: !dialerSimulation && allowRealOutboundCalls,
storageProvider: process.env.STORAGE_PROVIDER ?? "local",
eslConfigured: Boolean(process.env.ESL_HOST) && Boolean(process.env.ESL_PASSWORD),
corsOrigin: process.env.CORS_ORIGIN ?? null,
};
}
}

View File

@@ -0,0 +1,72 @@
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, UseGuards } from "@nestjs/common";
import { IsIn } from "class-validator";
import { getPrismaClient } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
class UpdateUserStatusDto {
@IsIn(["ACTIVE", "DISABLED"])
status!: "ACTIVE" | "DISABLED";
}
/**
* "Sistema > Usuários" (agente.md secao 168) — visão cross-tenant, só
* platform admin. `users` não tem RLS (identidade global, secao 148),
* então lida direto com `getPrismaClient()`; as memberships de cada
* usuário (que tenants ele pertence) É que têm RLS — resolvidas uma por
* uma via `withTenantContext`, mesmo padrão já usado em
* `TenantsController.list` pro `memberCount`.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/users")
export class PlatformUsersController {
@RequirePermission("users.manage")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode listar usuarios da plataforma");
}
const prisma = getPrismaClient();
const [users, platformAdmins] = await Promise.all([
prisma.user.findMany({
where: { deletedAt: null },
select: { id: true, email: true, name: true, status: true, mustChangePassword: true, createdAt: true },
orderBy: { createdAt: "desc" },
}),
prisma.userRole.findMany({ where: { tenantId: null, role: { key: "platform_super_admin" } }, select: { userId: true } }),
]);
const platformAdminIds = new Set(platformAdmins.map((r) => r.userId));
return users.map((u) => ({ ...u, isPlatformUser: platformAdminIds.has(u.id) }));
}
@RequirePermission("users.manage")
@Patch(":id/status")
async updateStatus(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateUserStatusDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode desabilitar usuarios");
}
const prisma = getPrismaClient();
const existing = await prisma.user.findFirst({ where: { id, deletedAt: null } });
if (!existing) throw new NotFoundException();
const updated = await prisma.user.update({ where: { id }, data: { status: dto.status } });
await recordAuditEvent(prisma, {
action: "USER_STATUS_UPDATE",
tenantId: null,
userId: user.sub,
entityType: "user",
entityId: id,
after: { status: dto.status },
});
return { id: updated.id, status: updated.status };
}
}

View File

@@ -0,0 +1,57 @@
import { Body, Controller, ForbiddenException, Get, Put, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { UpdateWebrtcProxyDto } from "./dto/update-webrtc-proxy.dto";
export const WEBRTC_PROXY_SETTING_KEY = "webrtc_proxy_url";
/**
* "Sistema > Softphone WebRTC" — endereço WSS do proxy OpenSIPS que faz a
* ponte WebRTC↔SIP pro widget Handphone embutido no app do tenant (PHASE
* 66, ver docs/SOFTPHONE.md). Config global (não por tenant): um único
* OpenSIPS atende todos os tenants, cada ramal continua puro SIP — o
* FreeSWITCH deste projeto nunca fala WebRTC diretamente.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform/webrtc-proxy")
export class PlatformWebrtcProxyController {
@RequirePermission("freeswitch.view")
@Get()
async get(@CurrentUser() user: AccessTokenClaims) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver esta configuracao");
}
const prisma = getPrismaClient();
const setting = await prisma.platformSetting.findUnique({ where: { key: WEBRTC_PROXY_SETTING_KEY } });
return { url: setting?.value ?? null };
}
@RequirePermission("freeswitch.configure")
@Put()
async update(@CurrentUser() user: AccessTokenClaims, @Body() dto: UpdateWebrtcProxyDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode editar esta configuracao");
}
const prisma = getPrismaClient();
const setting = await prisma.platformSetting.upsert({
where: { key: WEBRTC_PROXY_SETTING_KEY },
create: { key: WEBRTC_PROXY_SETTING_KEY, value: dto.url, updatedBy: user.sub },
update: { value: dto.url, updatedBy: user.sub },
});
await recordAuditEvent(prisma, {
action: "PLATFORM_WEBRTC_PROXY_UPDATE",
tenantId: null,
userId: user.sub,
entityType: "platform_setting",
entityId: WEBRTC_PROXY_SETTING_KEY,
after: { url: dto.url },
});
return { url: setting.value };
}
}

View File

@@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { PlatformOverviewController } from "./platform-overview.controller";
import { PlatformUsersController } from "./platform-users.controller";
import { PlatformAuditController } from "./platform-audit.controller";
import { PlatformHealthController } from "./platform-health.controller";
import { PlatformRolesController } from "./platform-roles.controller";
import { PlatformQuotasController } from "./platform-quotas.controller";
import { PlatformFreeswitchController } from "./platform-freeswitch.controller";
import { PlatformAiUsageController } from "./platform-ai-usage.controller";
import { PlatformSystemConfigController } from "./platform-system-config.controller";
import { PlatformWebrtcProxyController } from "./platform-webrtc-proxy.controller";
@Module({
controllers: [
PlatformOverviewController,
PlatformUsersController,
PlatformAuditController,
PlatformHealthController,
PlatformRolesController,
PlatformQuotasController,
PlatformFreeswitchController,
PlatformAiUsageController,
PlatformSystemConfigController,
PlatformWebrtcProxyController,
],
})
export class PlatformModule {}

View File

@@ -86,3 +86,80 @@ export class CreateQueueDto {
@IsBoolean() @IsBoolean()
recordingEnabled?: boolean; recordingEnabled?: boolean;
} }
export class UpdateQueueDto {
@IsOptional()
@IsString()
@MaxLength(80)
name?: string;
@IsOptional()
@IsString()
@MaxLength(255)
description?: string;
@IsOptional()
@IsIn(STRATEGIES)
strategy?: (typeof STRATEGIES)[number];
@IsOptional()
@IsString()
@MaxLength(255)
mohSound?: string;
@IsOptional()
@IsString()
@MaxLength(255)
announceSound?: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(3600)
announceFrequency?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(3600)
maxWaitTime?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(3600)
maxWaitTimeWithNoAgent?: number;
@IsOptional()
@IsString()
@MaxLength(80)
agentNoAnswerStatus?: string;
@IsOptional()
@IsBoolean()
tierRulesApply?: boolean;
@IsOptional()
@IsInt()
@Min(1)
@Max(3600)
tierRuleWaitSecond?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(3600)
discardAbandonedAfter?: number;
@IsOptional()
@IsBoolean()
abandonedResumeAllowed?: boolean;
@IsOptional()
@IsBoolean()
skipAgentsWithExternalCalls?: boolean;
@IsOptional()
@IsBoolean()
recordingEnabled?: boolean;
}

View File

@@ -7,6 +7,7 @@ import {
HttpStatus, HttpStatus,
NotFoundException, NotFoundException,
Param, Param,
Patch,
Post, Post,
UseGuards, UseGuards,
} from "@nestjs/common"; } from "@nestjs/common";
@@ -18,7 +19,7 @@ import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator"; import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
import { getRedisClient } from "../common/redis"; import { getRedisClient } from "../common/redis";
import { CreateQueueDto } from "./dto/create-queue.dto"; import { CreateQueueDto, UpdateQueueDto } from "./dto/create-queue.dto";
const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync"; const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync";
@@ -101,6 +102,58 @@ export class QueuesController {
return queue; return queue;
} }
/**
* Achado real reportado pelo usuário: "na fila nao tem opcao de editar
* a fila apos a criacao" — só existia create/delete até aqui.
*/
@RequirePermission("queues.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateQueueDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.queue.updateMany({
where: { id, tenantId, deletedAt: null },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.description !== undefined ? { description: dto.description } : {}),
...(dto.strategy !== undefined ? { strategy: dto.strategy } : {}),
...(dto.mohSound !== undefined ? { mohSound: dto.mohSound } : {}),
...(dto.announceSound !== undefined ? { announceSound: dto.announceSound } : {}),
...(dto.announceFrequency !== undefined ? { announceFrequency: dto.announceFrequency } : {}),
...(dto.maxWaitTime !== undefined ? { maxWaitTime: dto.maxWaitTime } : {}),
...(dto.maxWaitTimeWithNoAgent !== undefined ? { maxWaitTimeWithNoAgent: dto.maxWaitTimeWithNoAgent } : {}),
...(dto.agentNoAnswerStatus !== undefined ? { agentNoAnswerStatus: dto.agentNoAnswerStatus } : {}),
...(dto.tierRulesApply !== undefined ? { tierRulesApply: dto.tierRulesApply } : {}),
...(dto.tierRuleWaitSecond !== undefined ? { tierRuleWaitSecond: dto.tierRuleWaitSecond } : {}),
...(dto.discardAbandonedAfter !== undefined ? { discardAbandonedAfter: dto.discardAbandonedAfter } : {}),
...(dto.abandonedResumeAllowed !== undefined ? { abandonedResumeAllowed: dto.abandonedResumeAllowed } : {}),
...(dto.skipAgentsWithExternalCalls !== undefined ? { skipAgentsWithExternalCalls: dto.skipAgentsWithExternalCalls } : {}),
...(dto.recordingEnabled !== undefined ? { recordingEnabled: dto.recordingEnabled } : {}),
},
}),
);
if (result.count === 0) {
throw new NotFoundException();
}
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.queue.findFirstOrThrow({ where: { id } }));
await recordAuditEvent(prisma, {
action: "QUEUE_UPDATE",
tenantId,
userId: user.sub,
entityType: "queue",
entityId: id,
after: { ...dto },
});
await notifyQueuesChanged();
return updated;
}
@RequirePermission("queues.manage") @RequirePermission("queues.manage")
@Delete(":id") @Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)

View File

@@ -30,6 +30,75 @@ function topCounts(lists: string[][], limit: number): { value: string; count: nu
@UseGuards(JwtAuthGuard, PermissionGuard) @UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("reports") @Controller("reports")
export class ReportsController { export class ReportsController {
/**
* Dashboard do tenant (agente.md secao 162) — leitura ao vivo, não um
* relatório de período (por isso não aceita `from`/`to`, diferente dos
* outros endpoints deste controller). "Em andamento"/"Esperando agente"
* são o estado AGORA (`endAt: null`), o resto ("hoje", TME/TMA/rates) é
* escopado ao dia corrente (UTC, mesmo corte de
* `PlatformOverviewController`).
*
* "Consumo do plano" (secao 162) é ambíguo na especificação — decisão
* desta implementação: chamadas de hoje contra `Plan.maxDailyCalls`
* (número real disponível sem nenhuma inferência), não uma cifra
* monetária. "Valor estimado no mês" seria essa cifra monetária — fica
* `null` até existir um `BillingStatement` fechado no mês corrente
* (packages/billing, PHASE 22): nunca um valor calculado ad hoc fora do
* RatingEngine (docs/BILLING.md).
*/
@RequirePermission("dashboard.view")
@Get("dashboard")
async dashboard(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const monthStart = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
const [tenant, agentsByState, callsToday, callsAnsweredToday, callsInProgress, callsWaitingForAgent, answeredToday, abandonedToday, latestStatement] =
await withTenantContext(prisma, tenantId, (tx) =>
Promise.all([
tx.tenant.findUniqueOrThrow({ where: { id: tenantId }, include: { plan: true } }),
tx.agent.groupBy({ by: ["state"], where: { tenantId, deletedAt: null }, _count: true }),
tx.call.count({ where: { tenantId, createdAt: { gte: todayStart } } }),
tx.call.count({ where: { tenantId, createdAt: { gte: todayStart }, agentAnswerAt: { not: null } } }),
tx.call.count({ where: { tenantId, endAt: null } }),
tx.call.count({ where: { tenantId, endAt: null, queueEnterAt: { not: null }, agentAnswerAt: null } }),
tx.call.findMany({
where: { tenantId, createdAt: { gte: todayStart }, agentAnswerAt: { not: null } },
select: { waitTime: true, talkTime: true },
}),
tx.call.count({
where: { tenantId, createdAt: { gte: todayStart }, queueEnterAt: { not: null }, agentAnswerAt: null, endAt: { not: null } },
}),
tx.billingStatement.findFirst({
where: { tenantId, billingPeriod: { periodStart: { gte: monthStart } } },
orderBy: { generatedAt: "desc" },
}),
]),
);
const stateCount = (state: string) => agentsByState.find((row) => row.state === state)?._count ?? 0;
const waitTimes = answeredToday.map((c) => c.waitTime).filter((v): v is number => v != null);
const talkTimes = answeredToday.map((c) => c.talkTime).filter((v): v is number => v != null);
return {
callsToday,
callsAnsweredToday,
callsInProgress,
callsWaitingForAgent,
agentsAvailable: stateCount("AVAILABLE"),
agentsBusy: stateCount("RESERVED") + stateCount("RINGING") + stateCount("IN_CALL") + stateCount("WRAP_UP"),
agentsPaused: stateCount("PAUSED"),
tmeSeconds: average(waitTimes),
tmaSeconds: average(talkTimes),
answerRate: callsToday > 0 ? callsAnsweredToday / callsToday : null,
abandonRate: callsToday > 0 ? abandonedToday / callsToday : null,
dailyCallQuota: { used: callsToday, max: tenant.plan.maxDailyCalls },
monthlyConsumption: latestStatement ? { amount: latestStatement.total, currency: latestStatement.currency } : null,
};
}
/** Relatório de filas (secao 159): recebidas/atendidas/abandonadas/TME/ /** Relatório de filas (secao 159): recebidas/atendidas/abandonadas/TME/
* TMA/Service Level/Abandon Rate, agrupado por Queue. Service Level usa * TMA/Service Level/Abandon Rate, agrupado por Queue. Service Level usa
* um limiar configurável via query (`slThresholdSeconds`, default 20s — * um limiar configurável via query (`slThresholdSeconds`, default 20s —
@@ -274,4 +343,67 @@ export class ReportsController {
bottomAgents: agentRanking.slice(-5).reverse(), bottomAgents: agentRanking.slice(-5).reverse(),
}; };
} }
/** "Relatórios > Consumo" (secao 169) — uso bruto (minutos, dias
* ativos, armazenamento, tokens de IA), nunca valor em dinheiro: isso é
* trabalho do RatingEngine/BillingStatement (packages/billing,
* platform-only), não deste endpoint. Sem `from`/`to`, olha o mês
* corrente (mesmo corte usado por `Tenant.billingCurrency`/período de
* fechamento) — é "quanto eu já usei este mês", não um relatório
* histórico livre. Os 2 ledgers imutáveis (`UsageEvent`+
* `AIUsageRecord`) são a mesma fonte que o billing usa, então o número
* aqui bate exatamente com o que vira fatura depois. */
@RequirePermission("reports.view")
@Get("consumo")
async consumo(@CurrentUser() user: AccessTokenClaims, @Query("from") from?: string, @Query("to") to?: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const now = new Date();
const defaultFrom = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const range = { gte: from ? new Date(from) : defaultFrom, lte: to ? new Date(to) : now };
const [usageByMeter, aiUsageByType, tenant] = await withTenantContext(prisma, tenantId, (tx) =>
Promise.all([
tx.usageEvent.groupBy({ by: ["meter"], where: { tenantId, occurredAt: range }, _sum: { quantity: true } }),
tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId, occurredAt: range }, _sum: { quantity: true } }),
tx.tenant.findFirst({ where: { id: tenantId }, select: { plan: true } }),
]),
);
const usage: Record<string, number> = {};
for (const row of usageByMeter) usage[row.meter] = row._sum.quantity ?? 0;
const aiUsage: Record<string, number> = {};
for (const row of aiUsageByType) aiUsage[row.type] = row._sum.quantity ?? 0;
const plan = tenant?.plan ?? null;
const callCount = usage["CALL_COUNT"] ?? 0;
const recordingBytes = usage["RECORDING_BYTES"] ?? 0;
return {
period: { from: range.gte.toISOString(), to: range.lte.toISOString() },
usage: {
callCount,
callSeconds: usage["CALL_SECONDS"] ?? 0,
extensionActiveDays: usage["EXTENSION_ACTIVE_DAY"] ?? 0,
agentActiveDays: usage["AGENT_ACTIVE_DAY"] ?? 0,
trunkActiveDays: usage["TRUNK_ACTIVE_DAY"] ?? 0,
recordingBytes,
},
aiUsage: {
transcriptionSeconds: aiUsage["AI_TRANSCRIPTION_SECONDS"] ?? 0,
analysisRequests: aiUsage["AI_ANALYSIS_REQUEST"] ?? 0,
inputTokens: aiUsage["AI_INPUT_TOKENS"] ?? 0,
outputTokens: aiUsage["AI_OUTPUT_TOKENS"] ?? 0,
},
limits: plan && {
maxMonthlyCalls: plan.maxMonthlyCalls,
maxRecordingStorageGb: plan.maxRecordingStorageGb,
callCountUsedRatio: plan.maxMonthlyCalls ? callCount / plan.maxMonthlyCalls : null,
recordingStorageUsedRatio: plan.maxRecordingStorageGb
? recordingBytes / (plan.maxRecordingStorageGb * 1024 ** 3)
: null,
},
};
}
} }

View File

@@ -0,0 +1,69 @@
import { IsEmail, IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength } from "class-validator";
const TENANT_STATUSES = ["TRIAL", "ACTIVE", "SUSPENDED", "PAST_DUE", "CANCELLED"];
export class CreateTenantDto {
@IsString()
@MaxLength(40)
@Matches(/^[a-z0-9-]+$/, { message: "code deve ser minusculo, so letras/numeros/hifen (vira tambem o slug)" })
code!: string;
@IsString()
@MaxLength(200)
legalName!: string;
@IsOptional()
@IsString()
@MaxLength(200)
tradeName?: string;
@IsOptional()
@IsString()
@MaxLength(40)
taxId?: string;
@IsUUID()
planId!: string;
/** Domínio SIP deste tenant (secao 178, docs/EXTENSIONS.md) — achado
* real reportado pelo usuário: antes disto todo tenant nascia com o
* mesmo domínio fixo ("b2bcall.local"), quebrando isolamento de PABX
* (call groups, filas, IVR não conseguem distinguir um tenant do
* outro). A tela sugere `{code}.b2bcall.net`, mas quem cria pode
* digitar outro — só precisa ser único (constraint no banco) e ter
* cara de domínio (não precisa resolver de verdade por DNS: o
* telefone/softphone aponta pro IP do servidor via `proxy=`, este
* campo só serve pra identificar o tenant no REGISTER). */
@IsString()
@MaxLength(120)
@Matches(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/, {
message: "telephonyDomain precisa ter formato de domínio (ex.: minhaempresa.b2bcall.net)",
})
telephonyDomain!: string;
/** Cria o primeiro usuário (tenant_admin) na mesma transação — sem essa
* conta o tenant fica inacessível (secao 141: "Tenant Admin é criado
* junto com o tenant"). Senha gerada e devolvida uma única vez, mesmo
* padrão de `SecretReveal` já usado pra senha SIP de ramal. */
@IsEmail()
adminEmail!: string;
@IsString()
@MaxLength(120)
adminName!: string;
}
export class UpdateTenantDto {
@IsOptional()
@IsIn(TENANT_STATUSES)
status?: string;
@IsOptional()
@IsUUID()
planId?: string;
@IsOptional()
@IsString()
@MaxLength(200)
tradeName?: string;
}

View File

@@ -0,0 +1,29 @@
import { IsIn, IsOptional, IsString, MaxLength } from "class-validator";
const AI_PRIVACY_LEVELS = ["AI_OFF", "TRANSCRIPTION_ONLY", "TRANSCRIPTION_AND_ANALYSIS"];
export class UpdateTenantSettingsDto {
@IsOptional()
@IsString()
@MaxLength(200)
tradeName?: string;
@IsOptional()
@IsString()
@MaxLength(40)
taxId?: string;
@IsOptional()
@IsString()
@MaxLength(60)
timezone?: string;
@IsOptional()
@IsString()
@MaxLength(10)
locale?: string;
@IsOptional()
@IsIn(AI_PRIVACY_LEVELS)
aiPrivacyLevel?: string;
}

View File

@@ -0,0 +1,65 @@
import { Body, Controller, Get, NotFoundException, Patch, UseGuards } from "@nestjs/common";
import { getPrismaClient, type AIPrivacyLevel } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { UpdateTenantSettingsDto } from "./dto/update-tenant-settings.dto";
/**
* "Administração > Configurações" (agente.md secao 169) — self-service do
* próprio tenant, diferente de `TenantsController` (que é platform-only e
* mexe em campos que só a plataforma controla: status, plano, code).
* Nunca recebe `:id` — sempre `user.tenantId` das claims, então não existe
* forma de um Tenant Admin editar outro tenant por aqui (a checagem
* `isPlatformUser` do `TenantsController` não se aplica porque este
* controller nem aceita um tenantId arbitrário).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("tenant-settings")
export class TenantSettingsController {
@RequirePermission("users.manage")
@Get()
async get(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenant = await prisma.tenant.findFirst({
where: { id: user.tenantId!, deletedAt: null },
include: { plan: { select: { key: true, name: true } } },
});
if (!tenant) throw new NotFoundException();
const { priceBookId: _priceBookId, rateDeckId: _rateDeckId, ...rest } = tenant;
return rest;
}
@RequirePermission("users.manage")
@Patch()
async update(@CurrentUser() user: AccessTokenClaims, @Body() dto: UpdateTenantSettingsDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const tenant = await prisma.tenant.update({
where: { id: tenantId },
data: {
...(dto.tradeName !== undefined ? { tradeName: dto.tradeName } : {}),
...(dto.taxId !== undefined ? { taxId: dto.taxId } : {}),
...(dto.timezone !== undefined ? { timezone: dto.timezone } : {}),
...(dto.locale !== undefined ? { locale: dto.locale } : {}),
...(dto.aiPrivacyLevel !== undefined ? { aiPrivacyLevel: dto.aiPrivacyLevel as AIPrivacyLevel } : {}),
},
include: { plan: { select: { key: true, name: true } } },
});
await recordAuditEvent(prisma, {
action: "TENANT_SETTINGS_UPDATE",
tenantId,
userId: user.sub,
entityType: "tenant",
entityId: tenantId,
after: { ...dto },
});
const { priceBookId: _priceBookId, rateDeckId: _rateDeckId, ...rest } = tenant;
return rest;
}
}

View File

@@ -0,0 +1,203 @@
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma, type TenantStatus } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, hashPassword, type AccessTokenClaims } from "@b2bcall/auth";
import { generateStrongPassword } from "@b2bcall/shared";
import { buildDialplanXml, DEFAULT_DIALPLAN_EXTENSIONS } from "@b2bcall/telephony";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateTenantDto, UpdateTenantDto } from "./dto/create-tenant.dto";
/**
* CRUD de tenants (agente.md secao 29, 141, 168 "Clientes > Tenants") —
* a peça que faltava desde a PHASE 01: até aqui só existia via script/API
* direto. `tenants` não tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md),
* então lida direto com `getPrismaClient()`, nunca `withTenantContext`.
*
* `telephonyDomain` (secao 178, docs/EXTENSIONS.md) vem do `dto` agora,
* único por tenant (constraint no banco) — achado real reportado pelo
* usuário testando: até a PHASE 50 todo tenant novo nascia com o mesmo
* domínio fixo ("b2bcall.local"), quebrando isolamento de PABX de verdade
* (call groups, filas, IVR não tinham como distinguir um tenant do outro
* no REGISTER). O FreeSWITCH em si já aceita qualquer domínio dinamicamente
* (`<domain name="all" alias="true".../>` no sofia profile vanilla,
* confirmado lendo o container) — o bug era só este valor fixo aqui.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("tenants")
export class TenantsController {
@RequirePermission("tenants.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateTenantDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode criar tenants");
}
const prisma = getPrismaClient();
const [existingCode, existingDomain, existingEmail, plan] = await Promise.all([
prisma.tenant.findFirst({ where: { code: dto.code } }),
prisma.tenant.findFirst({ where: { telephonyDomain: dto.telephonyDomain } }),
prisma.user.findUnique({ where: { email: dto.adminEmail } }),
prisma.plan.findUnique({ where: { id: dto.planId } }),
]);
if (existingCode) throw new ForbiddenException(`Ja existe um tenant com code "${dto.code}"`);
if (existingDomain) throw new ForbiddenException(`Ja existe um tenant usando o dominio "${dto.telephonyDomain}"`);
if (existingEmail) throw new ForbiddenException(`Ja existe um usuario com o e-mail "${dto.adminEmail}"`);
if (!plan) throw new ForbiddenException("Plano nao encontrado");
const temporaryPassword = generateStrongPassword();
const passwordHash = await hashPassword(temporaryPassword);
const { tenant, admin } = await prisma.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: {
code: dto.code,
slug: dto.code,
legalName: dto.legalName,
tradeName: dto.tradeName,
taxId: dto.taxId,
planId: dto.planId,
telephonyDomain: dto.telephonyDomain,
},
});
const admin = await tx.user.create({
data: { email: dto.adminEmail, passwordHash, name: dto.adminName, mustChangePassword: true },
});
// RLS de tenant_memberships (secao 32) exige app.current_tenant_id —
// já dentro da mesma transação, sem precisar de withTenantContext
// (que abriria uma transação aninhada em cima de um PrismaClient
// plano, não de um TransactionClient).
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenant.id}, true)`;
await tx.tenantMembership.create({ data: { tenantId: tenant.id, userId: admin.id } });
const tenantAdminRole = await tx.role.findUniqueOrThrow({ where: { key: "tenant_admin" } });
await tx.userRole.create({ data: { userId: admin.id, roleId: tenantAdminRole.id, tenantId: tenant.id } });
// Discagem interna + pickup de grupo (PHASE 57, docs/DIALPLAN.md)
// — achado real: nenhum tenant nascia com isso, então nenhum
// conseguia discar entre os próprios ramais até alguém configurar a
// regra manualmente em Telefonia > Dialplan. Mesmas 2 regras já
// testadas ponta a ponta com chamada real (PHASE 53).
for (const ext of DEFAULT_DIALPLAN_EXTENSIONS) {
await tx.dialplanExtension.create({
data: {
tenantId: tenant.id,
context: "default",
name: ext.name,
conditionField: ext.conditionField,
conditionExpr: ext.conditionExpr,
actions: ext.actions as unknown as Prisma.InputJsonValue,
continueOnFalse: ext.continueOnFalse,
order: ext.order,
},
});
}
await tx.dialplanVersion.create({
data: {
tenantId: tenant.id,
context: "default",
version: 1,
generatedXml: buildDialplanXml("default", DEFAULT_DIALPLAN_EXTENSIONS),
status: "ACTIVE",
createdByUserId: user.sub,
activatedAt: new Date(),
},
});
return { tenant, admin };
});
await recordAuditEvent(prisma, {
action: "TENANT_CREATE",
tenantId: tenant.id,
userId: user.sub,
entityType: "tenant",
entityId: tenant.id,
after: { code: tenant.code, legalName: tenant.legalName, planId: tenant.planId, adminEmail: admin.email },
});
return { tenant, admin: { email: admin.email, temporaryPassword } };
}
@RequirePermission("tenants.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode listar tenants");
}
const prisma = getPrismaClient();
const tenants = await prisma.tenant.findMany({
where: { deletedAt: null },
include: { plan: { select: { id: true, key: true, name: true } } },
orderBy: { createdAt: "desc" },
});
// `tenant_memberships` tem FORCE RLS — nem platform admin enxerga
// linha nenhuma numa query sem `app.current_tenant_id` setado (deny-
// by-default, secao 32). Sem uma policy própria pra platform admin
// "ver tudo", a única forma correta é abrir o contexto de cada tenant
// um de cada vez — aceitável aqui, é uma tela de administração, não
// um hot path.
const memberCounts = await Promise.all(
tenants.map((t) => withTenantContext(prisma, t.id, (tx) => tx.tenantMembership.count({ where: { tenantId: t.id } }))),
);
const memberCount = new Map(tenants.map((t, i) => [t.id, memberCounts[i]]));
return tenants.map((t) => ({ ...t, memberCount: memberCount.get(t.id) ?? 0 }));
}
@RequirePermission("tenants.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver detalhe de tenant");
}
const prisma = getPrismaClient();
const tenant = await prisma.tenant.findFirst({
where: { id, deletedAt: null },
include: { plan: true },
});
if (!tenant) throw new NotFoundException();
return tenant;
}
@RequirePermission("tenants.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateTenantDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode editar tenants");
}
const prisma = getPrismaClient();
const existing = await prisma.tenant.findFirst({ where: { id, deletedAt: null } });
if (!existing) throw new NotFoundException();
if (dto.planId) {
const plan = await prisma.plan.findUnique({ where: { id: dto.planId } });
if (!plan) throw new ForbiddenException("Plano nao encontrado");
}
const tenant = await prisma.tenant.update({
where: { id },
data: {
...(dto.status ? { status: dto.status as TenantStatus } : {}),
...(dto.planId ? { planId: dto.planId } : {}),
...(dto.tradeName !== undefined ? { tradeName: dto.tradeName } : {}),
},
include: { plan: true },
});
await recordAuditEvent(prisma, {
action: "TENANT_UPDATE",
tenantId: tenant.id,
userId: user.sub,
entityType: "tenant",
entityId: tenant.id,
after: { ...dto },
});
return tenant;
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { TenantsController } from "./tenants.controller";
import { TenantSettingsController } from "./tenant-settings.controller";
@Module({
controllers: [TenantsController, TenantSettingsController],
})
export class TenantsModule {}

View File

@@ -0,0 +1,20 @@
import { IsEmail, IsIn, IsString, MaxLength } from "class-validator";
const TENANT_ROLE_KEYS = ["tenant_admin", "supervisor", "agent"];
export class InviteUserDto {
@IsEmail()
email!: string;
@IsString()
@MaxLength(120)
name!: string;
@IsIn(TENANT_ROLE_KEYS)
roleKey!: string;
}
export class UpdateUserRoleDto {
@IsIn(TENANT_ROLE_KEYS)
roleKey!: string;
}

View File

@@ -0,0 +1,35 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
/**
* "Administração > Perfis" (agente.md secao 169) — versão tenant-facing
* do catálogo de roles, só as de escopo TENANT (Tenant Admin/Supervisor/
* Agente). Diferente de `/platform/roles` (todas as roles, inclusive
* `platform_super_admin`, só platform admin) — um tenant não precisa
* saber que role de plataforma existe, só o que pode atribuir aos
* próprios usuários. `roles`/`permissions` não têm RLS (catálogo global
* do seed), mas o filtro por `scope: "TENANT"` já é suficiente aqui.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("roles")
export class RolesController {
@RequirePermission("users.manage")
@Get()
async list() {
const prisma = getPrismaClient();
const roles = await prisma.role.findMany({
where: { scope: "TENANT" },
include: { rolePermissions: { include: { permission: true } } },
orderBy: { name: "asc" },
});
return roles.map((r) => ({
key: r.key,
name: r.name,
permissionKeys: r.rolePermissions.map((rp) => rp.permission.key),
}));
}
}

View File

@@ -0,0 +1,217 @@
import { Body, ConflictException, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { recordAuditEvent, hashPassword, type AccessTokenClaims } from "@b2bcall/auth";
import { generateStrongPassword } from "@b2bcall/shared";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { InviteUserDto, UpdateUserRoleDto } from "./dto/invite-user.dto";
/**
* Usuários do tenant ativo (agente.md secao 169 "Administração > Usuários/
* Perfis"). Convite (secao 141) fecha a lacuna documentada desde a PHASE
* 22/29: até aqui só dava pra criar usuário junto com o tenant inteiro
* (Platform > Clientes > Tenants) ou via script — nenhuma forma de um
* Tenant Admin adicionar um colega ao próprio tenant.
*/
async function isLastTenantAdmin(tenantId: string, userId: string): Promise<boolean> {
const prisma = getPrismaClient();
const currentRole = await prisma.userRole.findFirst({ where: { userId, tenantId }, include: { role: true } });
if (currentRole?.role.key !== "tenant_admin") return false;
const otherAdmins = await prisma.userRole.count({
where: { tenantId, userId: { not: userId }, role: { key: "tenant_admin" } },
});
return otherAdmins === 0;
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("users")
export class UsersController {
@RequirePermission("users.manage")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const memberships = await withTenantContext(prisma, tenantId, (tx) =>
tx.tenantMembership.findMany({
where: { tenantId },
include: { user: { select: { id: true, email: true, name: true, status: true, deletedAt: true } } },
orderBy: { createdAt: "asc" },
}),
);
const activeMemberships = memberships.filter((m) => m.user.deletedAt == null);
const userIds = activeMemberships.map((m) => m.user.id);
const roles = userIds.length
? await prisma.userRole.findMany({
where: { userId: { in: userIds }, tenantId },
include: { role: true },
})
: [];
const roleByUserId = new Map(roles.map((r) => [r.userId, { key: r.role.key, name: r.role.name }]));
return activeMemberships.map((m) => ({
id: m.user.id,
email: m.user.email,
name: m.user.name,
status: m.user.status,
role: roleByUserId.get(m.user.id) ?? null,
}));
}
/** Convidar = criar o usuário se o e-mail ainda não existe (senha
* gerada, revelada uma vez só na resposta — mesmo padrão de
* `TenantsController.create`) ou só adicionar a membership+role se o
* e-mail já é de um usuário existente (sem tocar na senha dele). */
@RequirePermission("users.manage")
@Post()
async invite(@CurrentUser() user: AccessTokenClaims, @Body() dto: InviteUserDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const role = await prisma.role.findUnique({ where: { key: dto.roleKey } });
if (!role || role.scope !== "TENANT") {
throw new ForbiddenException("roleKey precisa ser uma role de escopo TENANT");
}
const existingUser = await prisma.user.findUnique({ where: { email: dto.email } });
if (existingUser) {
const existingMembership = await withTenantContext(prisma, tenantId, (tx) =>
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: existingUser.id } } }),
);
if (existingMembership) {
throw new ConflictException("Este usuário já é membro deste tenant");
}
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
await tx.tenantMembership.create({ data: { tenantId, userId: existingUser.id } });
await tx.userRole.create({ data: { userId: existingUser.id, roleId: role.id, tenantId } });
});
await recordAuditEvent(prisma, {
action: "USER_ADDED_TO_TENANT",
tenantId,
userId: user.sub,
entityType: "user",
entityId: existingUser.id,
after: { email: existingUser.email, roleKey: role.key },
});
return { user: { id: existingUser.id, email: existingUser.email, name: existingUser.name }, temporaryPassword: null };
}
const temporaryPassword = generateStrongPassword();
const passwordHash = await hashPassword(temporaryPassword);
const created = await prisma.$transaction(async (tx) => {
const newUser = await tx.user.create({
data: { email: dto.email, passwordHash, name: dto.name, mustChangePassword: true },
});
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
await tx.tenantMembership.create({ data: { tenantId, userId: newUser.id } });
await tx.userRole.create({ data: { userId: newUser.id, roleId: role.id, tenantId } });
return newUser;
});
await recordAuditEvent(prisma, {
action: "USER_INVITE",
tenantId,
userId: user.sub,
entityType: "user",
entityId: created.id,
after: { email: created.email, roleKey: role.key },
});
return { user: { id: created.id, email: created.email, name: created.name }, temporaryPassword };
}
@RequirePermission("users.manage")
@Patch(":id/role")
async updateRole(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateUserRoleDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const membership = await withTenantContext(prisma, tenantId, (tx) =>
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: id } } }),
);
if (!membership) throw new NotFoundException("Usuário não pertence a este tenant");
const role = await prisma.role.findUnique({ where: { key: dto.roleKey } });
if (!role || role.scope !== "TENANT") {
throw new ForbiddenException("roleKey precisa ser uma role de escopo TENANT");
}
if (role.key !== "tenant_admin" && (await isLastTenantAdmin(tenantId, id))) {
throw new ForbiddenException("Este e' o unico Tenant Admin do tenant — promova outra pessoa antes de trocar o papel dele(a)");
}
// Simplificação deliberada: 1 role por usuário por tenant — trocar
// substitui, não acumula (o schema permite várias, mas a UI não
// oferece combinar papéis nesta primeira versão).
await prisma.$transaction([
prisma.userRole.deleteMany({ where: { userId: id, tenantId } }),
prisma.userRole.create({ data: { userId: id, roleId: role.id, tenantId } }),
]);
await recordAuditEvent(prisma, {
action: "USER_ROLE_UPDATE",
tenantId,
userId: user.sub,
entityType: "user",
entityId: id,
after: { roleKey: role.key },
});
return { id, role: { key: role.key, name: role.name } };
}
/** Remove só a membership+role deste tenant (secao 169) — nunca a conta
* `User` em si, que pode pertencer a outros tenants. Bloqueia remover a
* si mesmo (evita um Tenant Admin se trancar fora sem querer) e remover
* o último Tenant Admin (o tenant ficaria sem ninguém que possa
* convidar/gerenciar gente). */
@RequirePermission("users.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
if (id === user.sub) {
throw new ForbiddenException("Você não pode remover a si mesmo deste tenant");
}
const membership = await withTenantContext(prisma, tenantId, (tx) =>
tx.tenantMembership.findUnique({ where: { tenantId_userId: { tenantId, userId: id } } }),
);
if (!membership) throw new NotFoundException("Usuário não pertence a este tenant");
if (await isLastTenantAdmin(tenantId, id)) {
throw new ForbiddenException("Este e' o unico Tenant Admin do tenant — promova outra pessoa antes de remove-lo(a)");
}
// `tenant_memberships` tem FORCE RLS (secao 32) — precisa de
// `app.current_tenant_id` setado na mesma transação, senão o delete
// não enxerga a linha e Prisma devolve P2025 ("not found") mesmo com
// a linha existindo (mesma classe de bug já corrigida em
// TenantsController.create). `user_roles` não tem RLS, então o
// deleteMany funciona em qualquer contexto.
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
await tx.userRole.deleteMany({ where: { userId: id, tenantId } });
await tx.tenantMembership.delete({ where: { tenantId_userId: { tenantId, userId: id } } });
});
await recordAuditEvent(prisma, {
action: "USER_REMOVED_FROM_TENANT",
tenantId,
userId: user.sub,
entityType: "user",
entityId: id,
});
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { RolesController } from "./roles.controller";
@Module({
controllers: [UsersController, RolesController],
})
export class UsersModule {}

View File

@@ -4,7 +4,7 @@ import formbody from "@fastify/formbody";
import Redis from "ioredis"; import Redis from "ioredis";
import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { decryptSecret } from "@b2bcall/shared"; import { decryptSecret } from "@b2bcall/shared";
import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony"; import { buildDirectoryUserXml, buildInboundRouteXml, NOT_FOUND_XML } from "@b2bcall/telephony";
import { createLogger } from "@b2bcall/shared"; import { createLogger } from "@b2bcall/shared";
import { syncTrunks } from "./trunk-sync"; import { syncTrunks } from "./trunk-sync";
import { syncQueues } from "./queue-sync"; import { syncQueues } from "./queue-sync";
@@ -52,9 +52,11 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
// tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem // tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem
// RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM // RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM
// tenant especifico depois de achar o dono do dominio. // tenant especifico depois de achar o dono do dominio. `telephonyDomain`
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: domain, status: "ACTIVE" } }); // e' @unique desde a PHASE 50 (achado real: antes disso todo tenant
if (!tenant) { // tinha o mesmo dominio fixo) — `findUnique` reflete essa garantia.
const tenant = await prisma.tenant.findUnique({ where: { telephonyDomain: domain } });
if (!tenant || tenant.status !== "ACTIVE") {
return NOT_FOUND_XML; return NOT_FOUND_XML;
} }
@@ -75,16 +77,82 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
callerIdNumber: extension.callerIdNumber ?? undefined, callerIdNumber: extension.callerIdNumber ?? undefined,
tenantId: extension.tenantId, tenantId: extension.tenantId,
extensionId: extension.id, extensionId: extension.id,
callGroup: extension.callGroup,
}); });
} }
/**
* Resolve o tenant dono de um DID pra uma chamada de ENTRADA (PHASE 56,
* docs/INBOUND_ROUTES.md) — achado real: uma chamada chegando pelo profile
* "external" (contexto "inbound", renomeado do "public" vanilla no
* Dockerfile) nunca carrega `variable_b2bcall_tenant_id`, porque essa
* variable só é setada em REGISTER de ramal ou originate de discagem
* (nunca em chamada recebida de tronco). `InboundRoute.didNumber` é a
* ÚNICA forma de descobrir de qual tenant é ANTES de saber o tenant —
* por isso é único GLOBAL (mesma exceção de `Tenant.telephonyDomain`).
* Fan-out sobre tenants ativos com `withTenantContext` (nunca uma query
* sem contexto — `InboundRoute` tem RLS de verdade, FORCE ROW LEVEL
* SECURITY), mesmo padrão já usado em `updateTrunkStatusFromGatewayEvent`.
*/
async function resolveInboundRouteXml(didNumber: string | undefined): Promise<string> {
if (!didNumber) return NOT_FOUND_XML;
const prisma = getPrismaClient();
const tenants = await prisma.tenant.findMany({
where: { status: "ACTIVE", telephonyDomain: { not: null } },
select: { id: true, telephonyDomain: true },
});
for (const tenant of tenants) {
const route = await withTenantContext(prisma, tenant.id, (tx) =>
tx.inboundRoute.findFirst({ where: { tenantId: tenant.id, didNumber, enabled: true, deletedAt: null } }),
);
if (route) {
const domain = tenant.telephonyDomain!;
// PHASE 62: fila e grupo nunca "discam um número" no dialplan do
// tenant — a resolução de entrada já emite a action final.
if (route.destinationType === "QUEUE") {
return buildInboundRouteXml({ tenantId: tenant.id, domain, destinationType: "QUEUE", queueId: route.destinationNumber });
}
if (route.destinationType === "CALL_GROUP") {
// Resolvido AGORA, nunca um snapshot salvo — trocar quem está no
// grupo depois de criar a rota já vale na PRÓXIMA chamada.
const members = await withTenantContext(prisma, tenant.id, (tx) =>
tx.extension.findMany({
where: { tenantId: tenant.id, callGroup: route.destinationNumber, enabled: true, deletedAt: null },
select: { number: true },
}),
);
return buildInboundRouteXml({
tenantId: tenant.id,
domain,
destinationType: "CALL_GROUP",
groupMembers: members.map((m) => m.number),
});
}
return buildInboundRouteXml({
tenantId: tenant.id,
domain,
destinationType: route.destinationType,
destinationNumber: route.destinationNumber,
destinationContext: route.destinationContext,
});
}
}
return NOT_FOUND_XML;
}
/** /**
* Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em * Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em
* toda chamada originada de um ramal nosso (ver buildDirectoryUserXml). * toda chamada originada de um ramal nosso (ver buildDirectoryUserXml) ou,
* Ao contrário do directory (resolvido por domain, que hoje é o mesmo pra * pra chamada de ENTRADA, pela própria resolução de rota acima (que seta
* todos os tenants — limitação conhecida, ver docs/EXTENSIONS.md), o * a variable antes de transferir — o `transfer` dispara esta função de
* dialplan já tem essa variável disponível na própria chamada, então nem * novo, já com o tenant presente). Ao contrário do directory (resolvido
* sofre da mesma ambiguidade. * por domain — cada tenant tem o seu agora, único no banco, PHASE 50/
* docs/EXTENSIONS.md), o dialplan já tem essa variável disponível na
* própria chamada, então nem depende de domain nenhum.
* *
* Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml) * Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml)
* — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as * — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as
@@ -96,6 +164,10 @@ async function resolveDialplanXml(body: XmlCurlBody): Promise<string> {
const context = (body["Caller-Context"] ?? body.context ?? "default") as string; const context = (body["Caller-Context"] ?? body.context ?? "default") as string;
if (!tenantId) { if (!tenantId) {
if (context === "inbound") {
const did = (body["Caller-Destination-Number"] ?? body["Hunt-Destination-Number"]) as string | undefined;
return resolveInboundRouteXml(did);
}
return NOT_FOUND_XML; return NOT_FOUND_XML;
} }

View File

@@ -92,7 +92,7 @@ export async function persistCallEvent(normalized: NormalizedEvent): Promise<voi
}); });
if (normalized.type === "CALL_ENDED") { if (normalized.type === "CALL_ENDED") {
await finalizeCall(tx, callId); await finalizeCall(tx, callId, tenantId);
} }
}); });
} catch (err) { } catch (err) {
@@ -155,9 +155,10 @@ function buildPatch(normalized: NormalizedEvent): CallPatch {
/** Calcula os agregados em segundos (secao 155-156) uma vez que a chamada /** Calcula os agregados em segundos (secao 155-156) uma vez que a chamada
* terminou — nunca antes, pra não gravar valores parciais. */ * terminou — nunca antes, pra não gravar valores parciais. */
async function finalizeCall(tx: Prisma.TransactionClient, callId: string): Promise<void> { async function finalizeCall(tx: Prisma.TransactionClient, callId: string, tenantId: string): Promise<void> {
const call = await tx.call.findUniqueOrThrow({ where: { id: callId } }); const call = await tx.call.findUniqueOrThrow({ where: { id: callId } });
const talkTime = seconds(call.bridgeAt, call.endAt); const talkTime = seconds(call.bridgeAt, call.endAt);
const billableSeconds = talkTime ?? 0;
await tx.call.update({ await tx.call.update({
where: { id: callId }, where: { id: callId },
@@ -166,7 +167,27 @@ async function finalizeCall(tx: Prisma.TransactionClient, callId: string): Promi
waitTime: seconds(call.queueEnterAt, call.agentAnswerAt), waitTime: seconds(call.queueEnterAt, call.agentAnswerAt),
talkTime, talkTime,
durationSeconds: seconds(call.createdAt, call.endAt), durationSeconds: seconds(call.createdAt, call.endAt),
billableSeconds: talkTime ?? 0, billableSeconds,
},
});
// "Chamada faturável" (agente.md secao 133) gera o UsageEvent que o
// RatingEngine (packages/billing) consome no fechamento do período —
// nunca calcula o valor aqui, só registra o fato bruto (segundos
// faturáveis). Chamada sem talk time (nunca bridgeou) não gera evento —
// nada a cobrar.
if (billableSeconds > 0) {
await tx.usageEvent.create({
data: {
tenantId,
callId,
meter: "CALL_SECONDS",
quantity: billableSeconds,
unit: "seconds",
sourceType: "call",
sourceId: callId,
occurredAt: call.endAt ?? new Date(),
}, },
}); });
} }
}

View File

@@ -0,0 +1,87 @@
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { createLogger } from "@b2bcall/shared";
import type { FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
const logger = createLogger("b2bcall-fs-events");
/**
* sofia::register/unregister/expire não carregam `b2bcall_tenant_id` (só
* existe como channel variable de CHAMADA, nunca de REGISTER) — resolve
* por domínio (from-host), único por tenant desde a PHASE 52/53. Mesmo
* padrão de fan-out do `updateTrunkStatusFromGatewayEvent`, mas aqui o
* domínio já identifica o tenant direto, sem precisar tentar cada um.
*/
export async function updateExtensionRegistrationStatus(
user: string | undefined,
host: string | undefined,
registered: boolean,
): Promise<void> {
if (!user || !host) return;
const prisma = getPrismaClient();
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: host }, select: { id: true } });
if (!tenant) {
logger.debug("nenhum tenant encontrado pro dominio do REGISTER", { host, user });
return;
}
const result = await withTenantContext(prisma, tenant.id, (tx) =>
tx.extension.updateMany({
where: { number: user, tenantId: tenant.id, deletedAt: null },
data: { registeredAt: registered ? new Date() : null },
}),
);
if (result.count > 0) {
logger.info("status de registro do ramal atualizado", { user, host, registered });
}
}
/**
* Reconciliação ao conectar/reconectar no ESL (secao 55): sem isto, um
* ramal que já estava registrado ANTES do fs-events subir (ou durante uma
* queda do serviço) ficaria com `registered_at` desatualizado até o
* próximo REGISTER natural do aparelho — minutos de atraso, dependendo do
* `registration-expires` configurado no softphone. `show registrations`
* é a fonte de verdade ao vivo: zera todo mundo do tenant primeiro,
* depois marca só quem está de fato na lista agora.
*/
export async function reconcileExtensionRegistrations(provider: FreeSwitchTelephonyProvider): Promise<void> {
const prisma = getPrismaClient();
let raw: unknown;
try {
raw = await provider.getRegistrations();
} catch (err) {
logger.error("falha ao consultar registrations pra reconciliacao", { error: String(err) });
return;
}
const rows = (raw as { rows?: Array<{ reg_user?: string; realm?: string }> } | undefined)?.rows ?? [];
const registeredByDomain = new Map<string, Set<string>>();
for (const row of rows) {
if (!row.reg_user || !row.realm) continue;
if (!registeredByDomain.has(row.realm)) registeredByDomain.set(row.realm, new Set());
registeredByDomain.get(row.realm)!.add(row.reg_user);
}
const tenants = await prisma.tenant.findMany({
where: { status: "ACTIVE", telephonyDomain: { not: null } },
select: { id: true, telephonyDomain: true },
});
for (const tenant of tenants) {
const registeredNumbers = Array.from(registeredByDomain.get(tenant.telephonyDomain!) ?? []);
await withTenantContext(prisma, tenant.id, async (tx) => {
await tx.extension.updateMany({
where: { tenantId: tenant.id, deletedAt: null, number: { notIn: registeredNumbers } },
data: { registeredAt: null },
});
if (registeredNumbers.length > 0) {
await tx.extension.updateMany({
where: { tenantId: tenant.id, deletedAt: null, number: { in: registeredNumbers } },
data: { registeredAt: new Date() },
});
}
});
}
logger.info("reconciliacao de registrations de ramais concluida", { tenantsChecked: tenants.length });
}

View File

@@ -3,6 +3,7 @@ import type { FreeSwitchEventData } from "esl";
import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony"; import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony";
import { createLogger } from "@b2bcall/shared"; import { createLogger } from "@b2bcall/shared";
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status"; import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
import { updateExtensionRegistrationStatus, reconcileExtensionRegistrations } from "./extension-status";
import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve"; import { resolveTenantIdForAgent, resolveTenantIdForQueue } from "./tenant-resolve";
import { persistCallEvent } from "./cdr"; import { persistCallEvent } from "./cdr";
import { uploadRecordingIfPresent } from "./recording"; import { uploadRecordingIfPresent } from "./recording";
@@ -90,6 +91,10 @@ async function main() {
call.on(eventName, (raw) => handleEvent(eventName, raw)); call.on(eventName, (raw) => handleEvent(eventName, raw));
} }
call.on("CUSTOM", (raw) => handleEvent("CUSTOM", raw)); call.on("CUSTOM", (raw) => handleEvent("CUSTOM", raw));
reconcileExtensionRegistrations(provider).catch((err) => {
logger.error("falha na reconciliacao de registrations de ramais", { error: String(err) });
});
}); });
client.on("reconnecting", (retryMs) => { client.on("reconnecting", (retryMs) => {
@@ -170,6 +175,14 @@ async function main() {
logger.error("falha ao atualizar status do trunk", { error: String(err), gateway }); logger.error("falha ao atualizar status do trunk", { error: String(err), gateway });
}); });
} }
if (normalized.type === "EXTENSION_REGISTERED" || normalized.type === "EXTENSION_UNREGISTERED") {
const user = normalized.data.user as string | undefined;
const host = normalized.data.host as string | undefined;
updateExtensionRegistrationStatus(user, host, normalized.type === "EXTENSION_REGISTERED").catch((err) => {
logger.error("falha ao atualizar status de registro do ramal", { error: String(err), user, host });
});
}
} }
provider.connect(); provider.connect();

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Some files were not shown because too many files have changed in this diff Show More