Compare commits

..

9 Commits

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 13:46:52 -03:00
9d3c84064f fix(ui): botão de copiar senha não fazia nada em HTTP puro (fora de localhost)
navigator.clipboard só existe em contexto seguro (HTTPS ou localhost) —
este ambiente é HTTP puro, acessado pelo IP real da VM. SecretReveal
(usado na criação de ramal e em "ver senha atual") chamava
navigator.clipboard.writeText sem tratamento de erro, então em contexto
inseguro o botão simplesmente não fazia nada, sem nenhum aviso.

Fix: fallback pra document.execCommand("copy") (textarea temporário)
quando a Clipboard API moderna não existe ou falha, funciona em qualquer
contexto. Se as duas formas falharem, mostra aviso pra copiar manualmente
em vez de falhar em silêncio.

Testado com Playwright acessando o IP real da VM (não localhost, pra
reproduzir o contexto inseguro de verdade): confirmado
navigator.clipboard.writeText undefined nesse contexto (reproduz o
sintoma exato), e os dois fluxos de senha mostram sucesso via o fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 13:11:03 -03:00
b33d35db50 fix(softphone): widget nunca calculava o AOR quando config vinha via data-sip-* — ficava preso em "desconectado" pra sempre
Usuário configurou o proxy WebRTC de QA de verdade (Platform > Sistema >
Softphone WebRTC) e tentou registrar o ramal 1501 (tenant teste01) — o
widget só ficava cinza, sem nenhum erro visível.

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 12:48:33 -03:00
2134fa0aa4 fix(freeswitch): 3 achados reais de NAT entre ramais no mesmo escritório — registro, áudio e chamada morrendo em 32s (PHASE 68)
Usuário reportou que 1502 ligando pra 1503 (dois telefones IP reais,
ambos registrados) não completava a chamada. Três bugs em camadas
diferentes, cada um só confirmado lendo o log/capturando pacote real —
nunca por suposição:

1. Registro: os dois ramais registravam com Contact apontando pro MESMO
   IP público compartilhado desta rede (a VM nunca tem IP público
   próprio, é sempre RFC1918 atrás do NAT do escritório) — originar uma
   chamada tentava mandar o INVITE de volta pra esse IP público, hairpin
   NAT clássico, falha instantânea (503). Fix: NDLB-received-in-nat-reg-
   contact (Contact salvo vira o IP realmente observado no pacote).

   De quebra, aplicado network_mode: host no serviço freeswitch (pedido
   explícito do usuário) — tira o Docker NAT/bridge do meio. Quebra em
   cascata corrigida: fs-config vira alcançável só via 127.0.0.1:8080
   (não mais nome de serviço), workers ESL (fs-events/predictive-dialer)
   passam a usar host.docker.internal.

2. Áudio: com o registro corrigido, a chamada completava mas sem RTP —
   local-network-acl="localnet.auto" só cobria a subnet da própria
   interface do FreeSWITCH, tratando ramais de OUTRAS subnets do mesmo
   escritório como "de fora" e trocando o SDP pelo IP público de novo.
   Fix: ACL própria (b2bcall_lan, cobre todo RFC1918) referenciada em
   local-network-acl.

3. Chamada morrendo sozinha em exatos 32s (Timer H do RFC 3261): mesmo
   com áudio ok, o 200 OK que o FreeSWITCH manda pro ramal que recebeu a
   chamada ainda tinha Contact com o IP público — o telefone nunca manda
   o ACK de volta, FreeSWITCH retransmite sozinho até desistir. Só
   confirmado com tcpdump (instalado nesta sessão) capturando o pacote
   byte a byte. Fix: ext-rtp-ip/ext-sip-ip (STUN, sempre resolvem pro IP
   público) removidos do profile "internal" — sem endereço "externo"
   configurado, o FreeSWITCH nunca mais tem como escolher errado.

Confirmado resolvido pelo usuário com chamadas reais nos dois sentidos,
áudio bidirecional, sobrevivendo bem além dos 32s que travavam antes.

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-31 09:41:22 -03:00
33 changed files with 1266 additions and 233 deletions

327
TODO.md
View File

@@ -2417,6 +2417,333 @@ já em produção] que depois vai vir via sip comum ao b2bcall")
`sip-user`/`sip-domain`/`sip-server` corretos e a senha presente `sip-user`/`sip-domain`/`sip-server` corretos e a senha presente
(não vazia) assim que o agente com ramal vinculado abre `/app`. (não vazia) assim que o agente com ramal vinculado abre `/app`.
## PHASE 67 — Dropdown de destino (fila/outro IVR) numa opção de IVR
(pedido do usuário: "dentro do IVR tem que dar a opção de eu colocar como
destino não apenas os ramais mas tb as filas e outra IVR, como feito na
rota de entrada")
- [x] Mesmo gap que existia em Rotas de Entrada até a PHASE 62:
`IvrMenuOption` só tinha `destinationNumber` (sempre interpretado
como ramal), sem tipo nenhum. Novo enum próprio
`IvrOptionDestinationType` (EXTENSION/QUEUE/IVR) — SEM
`CALL_GROUP` de propósito: diferente de `InboundRoute` (resolvido
por chamada, sempre fresco), o dialplan de um IVR é compilado UMA
VEZ ao salvar o menu — "quem está no grupo agora" ficaria
desatualizado até a próxima edição, comportamento silenciosamente
stale que ninguém pediu.
- [x] `buildIvrDialplanExtensions` (packages/telephony) — cada branch de
dígito decide a action pelo `destinationType`, mesmo padrão de
`buildInboundRouteXml`: EXTENSION continua `bridge` direto;
QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro
`IVR_ENTRY_DESTINATION` do contexto do menu alvo — reaproveita a
MESMA entrada que uma `InboundRoute` usa pra entrar naquele menu.
- [x] "Outro IVR" cria um grafo entre menus que nunca existia antes (uma
`InboundRoute` não é ela mesma um menu, nunca podia formar ciclo).
`assertNoIvrCycle` (novo, `ivr-menus.controller.ts`) monta o grafo
com TODOS os menus do tenant antes de compilar e rejeita
(409) qualquer combinação que criaria um ciclo — A→B→A ou até
A→A — checado tanto em `create` quanto `update`.
- [x] Frontend: os dois editores de IVR (form clássico `ivr-view.tsx` e
o editor visual `ivr-flow-editor.tsx`, PHASE 60/61) ganharam o
mesmo dropdown "Tipo" + "Destino" condicional já usado em Rotas de
Entrada — precisou buscar `queues` na `page.tsx` do IVR (só
`extensions` era buscado antes) e passar a lista de OUTROS menus
(excluindo o próprio, de propósito, pra reduzir a chance de
self-reference direto na UI — o backend segue sendo quem
garante isso de verdade).
- [x] Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR
reais criados na hora: Menu A com opção tipo Fila (aponta pra
"Fila QA"), Menu B com opção tipo Outro IVR (aponta pro Menu A) —
os dois persistiram com o tipo certo, sobrevivendo a um reload
completo da página.
## PHASE 68 — Ramal registrado mas chamada não completava / sem áudio
(reportado pelo usuário testando com 2 telefones IP reais, 1502 e 1503)
- [x] **Achado 1 (chamada não completava, 503 instantâneo)**: os dois
ramais registravam com o Contact apontando pro MESMO IP público
compartilhado desta rede (esta VM nunca tem IP público próprio —
é sempre RFC1918 atrás do NAT do escritório). Ao originar uma
chamada, o FreeSWITCH tentava mandar o INVITE de volta pra esse
IP público — hairpin NAT clássico, o roteador do escritório não
sabe refletir esse tráfego de volta pra dentro. Corrigido com
`NDLB-received-in-nat-reg-contact` (Dockerfile) — o Contact salvo
passa a ser o IP/porta REALMENTE observados no pacote (a rede
interna), não o que o telefone auto-relata.
- [x] `network_mode: host` no serviço `freeswitch` (pedido do usuário,
"sempre é problema NAT do Docker") — tira a camada de NAT/bridge
do Docker do meio. Quebra em cascata corrigida: FreeSWITCH sai da
rede bridge do compose, então (a) não resolve mais os OUTROS
containers pelo nome de serviço — `fs-config` virou
`http://127.0.0.1:8080/` (porta publicada só em loopback,
xml_curl.conf.xml) —, e (b) os workers que chamavam ESL via nome
`freeswitch` (fs-events/fs-config/predictive-dialer) passam a usar
`host.docker.internal` (+ `extra_hosts: host-gateway`, Docker
20.10+). Segurança do Event Socket deixa de depender de publicar
só em loopback (não existe mais essa camada) e passa a depender
inteiramente da ACL `b2bcall_internal` já configurada
(`apply-inbound-acl`) — confirmado que continua rejeitando
qualquer origem fora de loopback/rede do Docker.
- [x] **Achado 2 (chamada completava, mas sem áudio)**: com o registro
corrigido, a ligação passou a completar, mas sem RTP (confirmado
pelo usuário com `sngrep`). `local-network-acl="localnet.auto"`
(padrão vanilla) só cobre a subnet da PRÓPRIA interface do
FreeSWITCH — os ramais reais ficam em OUTRAS subnets do mesmo
escritório, então eram tratados como "de fora" e o SDP trocava o
RTP/SIP anunciado pelo IP público compartilhado de novo (mesmo
hairpin, agora na mídia). Corrigido com uma ACL própria
(`b2bcall_lan`, cobre todo RFC1918 — nunca alcançável de verdade
"de fora" nesta VM) referenciada em `local-network-acl`.
- [x] **Achado 3 (chamada completava e tinha áudio, mas morria sozinha
em exatos 32s)**: mesmo com áudio já corrigido, toda chamada
morria sozinha depois de exatos 32 segundos — o Timer H do
RFC 3261 (64×T1, prazo padrão de qualquer UAS esperando o ACK de
um 2xx). Só descoberto de verdade com captura de pacote real
(`tcpdump`, instalado nesta sessão — não estava no host): o
`200 OK` que o FreeSWITCH manda pro ramal que RECEBEU a chamada
ainda tinha `Contact: sip:...@191.240.175.55:5060` (o IP público
de novo, MESMO com o SDP já mostrando o IP certo) — o telefone
nunca manda o ACK de volta (não tem como chegar por ali), o
FreeSWITCH retransmite o 200 OK sozinho (0.5s/1s/2s/4s/4s...) até
desistir e derrubar a chamada. `apply-nat-acl`/`local-network-acl`
(achados 1/2) não bastam aqui — são checagens independentes, e
trocar `apply-nat-acl` pra escopo `b2bcall_lan` (testado) não
mudou nada nesse Contact. O fix de verdade: `ext-rtp-ip`/
`ext-sip-ip` (STUN, resolvem pro IP público) REMOVIDOS do profile
`internal` (Dockerfile) — sem endereço "externo" nenhum
configurado, o FreeSWITCH não tem mais como escolher errado,
cai sempre em `rtp-ip`/`sip-ip` (10.10.32.138). Único profile
afetado; `external` (reservado pra um trunk PSTN real, nunca
usado ainda) mantém os dois params intactos.
- [x] Diagnosticado ao vivo lendo `/var/log/freeswitch/freeswitch.log`
em tempo real durante as tentativas de chamada do usuário (nunca
supondo causa sem ver o log) e, pro achado 3, uma captura de
pacote real via `tcpdump` — cada achado confirmado por evidência
exata (`NORMAL_TEMPORARY_FAILURE`/503 instantâneo pro achado 1;
endereço do `c=`/RTP-IP pro achado 2; cabeçalho `Contact:` do
`200 OK` capturado byte a byte pro achado 3). Confirmado resolvido
pelo usuário com chamadas reais nos dois sentidos (1502→1503 e
1503→1502), áudio bidirecional e chamada sobrevivendo bem além
dos 32s que travavam toda tentativa antes do achado 3.
## PHASE 69 — Sessão web expirando cedo demais pra call center (pedido do
usuário: "no minimo 12 hs")
- [x] Access token JWT (`packages/auth/src/tokens.ts`) e o teto absoluto do
cookie de sessão (duplicado em `login`/`post-login`/`select-tenant`
route handlers + `middleware.ts`) subiram de 15min/8h pra 12h. O
refresh silencioso do middleware (decodifica o `exp` do JWT, renova
via `/auth/refresh` uns 60s antes de vencer) continua existindo e
deslizando a sessão a cada requisição — 12h passa a ser só o teto de
segurança pra quando esse refresh falhar (aba parada, logout em
outro lugar), não o intervalo real de renovação.
- [x] Verificado com login real: token decodificado mostra expiat = 12h
exatas.
## PHASE 70 — Softphone WebRTC não registrava, ficava cinza pra sempre
(usuário configurou o proxy QA de verdade e testou o ramal 1501 do tenant
teste01 — "so fica cinza")
- [x] Confirmado primeiro que a config em si estava certa
(`GET /platform/webrtc-proxy` devolvia a URL nova) e que a
infraestrutura de rede/proxy funcionava — DNS resolve, TCP conecta,
certificado TLS válido, e um handshake WebSocket manual (`ws`, com
header `Origin`) completa normal. Um REGISTER SIP cru (autenticação
digest manual) através do proxy chegou até o FreeSWITCH de verdade
(`User-Agent: FreeSWITCH-mod_sofia` na resposta) e voltou 200 OK —
credenciais do ramal 1501 corretas, proxy e FreeSWITCH 100% ok.
- [x] **Achado real, bug no próprio código-fonte do widget** (não
descoberto antes porque a verificação da PHASE 66 só conferia se o
`<script data-sip-*>` recebia os atributos certos, nunca deixava o
widget tentar registrar de verdade contra um proxy ao vivo):
`widgetStorage.mergeConfig` (`src/widget/widgetStorage.js`) só
calculava o campo `aor` (`sip:user@domain`) no branch de config
SALVA MANUALMENTE (localStorage) — no branch usado quando a config
vem de `data-sip-*`/`window.HandphoneConfig` (nosso caso, sempre),
`aor` nunca era calculado. `connect()` (`useSip.js`) quebra em
`cfg.aor.startsWith(...)` com `aor` undefined — erro só visível no
console do navegador (`[Widget] Connect failed: TypeError...`),
nunca pro usuário, que só via o botão preso em cinza (desconectado)
pra sempre, sem nenhuma tentativa de conexão visível.
- [x] Fix: `mergeConfig` agora calcula `aor` a partir de
`externalConfig.username`/`externalConfig.domain` também no branch
sem config salva. Patch na mesma cópia local do
`git.falehandix.com.br/Handix/handphone-2.0` já usada na PHASE 66
(nunca enviado pro repo externo do usuário) — rebuildado e
revendorizado em `apps/frontend/public/handphone.js`.
- [x] Testado ponta a ponta com Playwright + navegador real, fora do app
(uma página HTML isolada com os `data-sip-*` do ramal 1501 de
verdade, sem depender do login do tenant): REGISTER completa com
200 OK observado no tráfego WebSocket real, botão do widget muda de
cinza pra verde (conectado).
## PHASE 71 — Botão de copiar senha SIP não fazia nada (pedido do
usuário: "o botão de copiar a senha dentro do cadastro do ramal está com
problema")
- [x] **Achado real**: `navigator.clipboard` só existe em "contexto
seguro" (HTTPS ou `localhost`) — este ambiente é HTTP puro,
acessado pelo IP da VM (nunca localhost pro usuário de verdade).
`SecretReveal` (usado tanto na criação de ramal quanto em "ver
senha atual") chamava `navigator.clipboard.writeText` direto, sem
try/catch — em contexto inseguro isso derruba/rejeita sem erro
visível nenhum, o botão simplesmente não fazia nada. Confirmado
com Playwright acessando `http://10.10.32.138:3001` (IP real, não
localhost): `navigator.clipboard.writeText` de fato `undefined`
nesse contexto — reproduz o sintoma exato.
- [x] Fix: fallback pra `document.execCommand("copy")` (textarea
temporário fora da tela) quando a Clipboard API moderna não existe
ou falha — funciona em qualquer contexto, inclusive HTTP. Se as
duas formas falharem, mostra aviso pra selecionar e copiar
manualmente (Ctrl+C) em vez de falhar em silêncio — o campo já é
`select-all`.
- [x] Testado ponta a ponta com Playwright acessando o IP real da VM
(não localhost, pra genuinamente cair no contexto inseguro): os
dois fluxos (senha mostrada na criação do ramal, e "ver senha
atual" de um ramal já existente) mostram o ícone verde de sucesso
via o fallback.
## PHASE 72 — Widget do softphone voltou a ficar preso em cinza (pedido
do usuário: "o widget não conecta mais fica só cinza")
- [x] **Achado real**: só reproduzia dentro do app de verdade (tenant
logado), nunca numa página HTML isolada — Playwright com log de
diagnóstico (`new Error().stack`) provou que `connect()` era
chamado duas vezes em sequência rápida a partir do MESMO efeito de
auto-connect do `Widget.jsx`: o efeito de "auto-connect no mount"
dispara `handleConnect`, e se essa primeira tentativa falha (ou
demora), o efeito de "auto-reconnect quando cai" também dispara —
mas ele só grava `lastReconnectRef` (o cooldown de 5s) quando É ELE
quem chama `handleConnect`, nunca quando é o efeito de mount que
chama. Resultado: a primeira tentativa falha, o efeito de
reconexão vê o cooldown zerado e dispara uma segunda tentativa
SEM NENHUM atraso, criando um segundo `SimpleUser`/registro
simultâneo ao primeiro — sip.js rejeita com `RequestPendingError`
("REGISTER request already in progress"), e o `catch` do
`connect()` sobrescreve `connectionStatus` pra `"disconnected"`
incondicionalmente, travando o botão em cinza pra sempre mesmo que
a primeira tentativa estivesse indo bem.
- [x] Fix (mesma cópia local patcheada do
`git.falehandix.com.br/Handix/handphone-2.0`, nunca enviada pro
repo externo): (1) `useSip.js::connect()` agora é só um wrapper com
trava (`connectPromiseRef`) — uma chamada concorrente reaproveita a
MESMA promise em andamento em vez de abrir um `SimpleUser` novo
colidindo com o primeiro; (2) `Widget.jsx`, o efeito de
auto-connect no mount agora também grava `lastReconnectRef` antes
de chamar `handleConnect`, então o cooldown de 5s do efeito de
reconexão vale pra QUALQUER tentativa automática, não só as dele
próprio. Rebuildado e revendorizado em
`apps/frontend/public/handphone.js`.
- [x] Testado ponta a ponta com Playwright dentro do app de verdade
(tenant/ramal/agente criados na hora via fluxo normal da UI, sem
tocar em conta real): antes do fix, `connect()` disparava duas
vezes e o widget acabava preso em `"disconnected"`; depois do fix,
dispara uma única vez (`doConnect() ENTER, count= 1` — confirmado
em 4 execuções seguidas) e o status evolui
`connecting → connected` sem nunca voltar pra cinza.
## PHASE 73 — Editar ramal (nome/caller ID/contexto/codecs/grupo) no
mesmo lugar da senha (pedido do usuário: "quando clicar no ramal pra
editar a senha ou ver a senha atual já abra pra alterar o nome, o
callerid, contexto, a lista de codec disponível e grupo de captura com
uma lista dos que já foram criados, e um botão de salvar")
- [x] `UpdateExtensionDto` só aceitava `callerIdName`/`callerIdNumber`/
`maxRegistrations`/`callGroup` — `name`, `context` e `codecs`
existiam no schema (`codecs` com default `"PCMU,PCMA,OPUS"`) mas
não tinham caminho de update nenhum. Adicionados os três, `codecs`
validado contra a lista fixa `AVAILABLE_CODECS` (PCMU/PCMA/OPUS/
G722/G729/GSM, mesma constante espelhada no frontend).
- [x] **Achado real construindo isto**: a coluna `codecs` nunca tinha
sido ligada em nada — `buildDirectoryUserXml` (directory XML que o
FreeSWITCH consulta via mod_xml_curl) não emitia variável nenhuma
pra ela, então mudar o campo não teria efeito real nenhum na
negociação SDP do ramal. Adicionada a variável `absolute_codec_string`
(nome padrão do FreeSWITCH pra restringir/ordenar os codecs de um
usuário específico) em `packages/telephony/src/directory-xml.ts`,
e `apps/freeswitch-config/src/main.ts` passa `extension.codecs`
pra ela — agora o campo realmente controla o que o FreeSWITCH
oferece nesse ramal, não só um valor decorativo no banco.
- [x] Frontend: painel único "Configuração e senha SIP" em
`ramais/[id]/page.tsx` — a antiga `dl` só-leitura virou um
formulário (`ExtensionEditForm` em `actions-panel.tsx`) com nome,
caller ID (nome+número), contexto, checkboxes dos codecs
disponíveis e grupo de captura, tudo num "Editar configuração" +
Salvar, bem ao lado de "Ver senha atual"/"Redefinir senha" (mesmo
painel, não mais escondido em telas separadas). Grupo de captura
usa um `<input list>` com `<datalist>` alimentado pelos grupos já
cadastrados nos OUTROS ramais do tenant (busca `GET /extensions`
na própria server component da página) — escolhe um já existente
ou digita um novo, sem re-digitar do zero. Substituiu o antigo
`CallGroupEditAction` (isolado, só esse campo).
- [x] `callerIdName`/`callerIdNumber` agora aceitam `null` explícito pra
limpar o valor (antes só dava pra trocar por outro texto, nunca
voltar a vazio, porque o form mandava `undefined` — que o backend
trata como "não mudar nada" — em vez de `null`). Mesmo padrão que
`callGroup` já usava.
- [x] Testado ponta a ponta com Playwright: tenant descartável com dois
ramais, um com grupo de captura "vendas"; abriu o segundo ramal,
"Editar configuração" mostrou "vendas" na lista suspensa (vindo do
OUTRO ramal), editou nome/caller ID/contexto, desmarcou OPUS —
salvou e a tela voltou mostrando exatamente os valores novos
(`codecs: "PCMU,PCMA"`, grupo "vendas" reaproveitado). Precisou
`systemctl restart b2bcall-api.service` pra pegar o DTO novo —
`apps/api` roda `tsc` uma vez no start (não é watch mode), então
mudança de `src/` só entra em produção depois do restart.
## PHASE 74 — Widget continuava sem conectar num ramal real, mesmo depois
da PHASE 72 (pedido do usuário: testar `teste@teste.com.br` no tenant
`teste01.b2bcall.net`, ramal 1501 — "não conecta o widget", depois "o
botão de conectar fica só a setinha e não vira mão")
- [x] **Achado real #1 (o de verdade travava tudo)**: `widgetStorage.
mergeConfig` fazia `{ ...externalConfig, ...stored }` — o config
salvo no `localStorage` de uma sessão/ramal ANTERIOR nesse mesmo
navegador sempre VENCIA sobre o `username`/`domain`/`password`
que o backend acabou de injetar via `data-sip-*` pro agente
logado agora. Resultado: o widget tentava registrar com
credenciais de outro ramal (ou senha vazia, ver achado #2) pra
sempre, sem nenhum erro visível — só ficava cinza. Confirmado com
Playwright: pré-populei um `sip_config` "errado" no localStorage
(ramal 9999 fake) e recarreguei a página — antes do fix o widget
tentava usar 9999; depois do fix, sempre usa o ramal certo do
agente logado (`externalConfig` vence, `stored` só preenche o que
faltar). Invertido pra `{ ...stored, ...externalConfig }`.
- [x] **Achado real #2 (encontrado pelo usuário direto no console)**: ao
abrir a aba "Config" do widget e clicar "Salvar", `Uncaught
(in promise) TypeError: Cannot read properties of undefined
(reading 'importKey')` — mesma classe de bug da PHASE 71
(clipboard): `crypto.subtle` (SubtleCrypto) só existe em contexto
seguro (HTTPS ou localhost), e este ambiente é HTTP puro num
domínio/IP real. `widgetStorage.encryptPassword`/`decryptPassword`
chamavam `crypto.subtle.importKey` sem checar disponibilidade —
qualquer tentativa de salvar a senha nas Configurações do widget
quebrava e nada era persistido. Fix: `hasSubtleCrypto()` detecta a
ausência e cai pra um base64 reversível prefixado `plain:` (não é
criptografia de verdade, mas não quebra o widget em ambiente sem
HTTPS ainda) — `decryptPassword` reconhece o prefixo e volta a
funcionar nos dois casos.
- [x] Testado com Playwright: tenant/ramal descartáveis, salvei um
config "errado" na aba Configurações (sem crash, mensagem "salvas
com sucesso" aparece — confirma o fix do achado #2 nesse ambiente
onde localhost conta como contexto seguro) e recarreguei a
página — o próximo `connect()` usou o `aor`/`username` do ramal
real injetado pelo backend, não o salvo (confirma o fix do achado
#1). Rebuildado e revendorizado em `apps/frontend/public/
handphone.js`.
- [x] Não decifrei a senha do ramal 1501 real pra testar diretamente
(bloqueado pelo classifier de auto mode ao tentar rodar um script
ad-hoc contra o banco de produção) — a investigação usou só dados
já expostos por endpoints existentes (`sofia status`, logs do
`fs-config`, a config do agente via `getSoftphoneConfig`) e o
console do navegador que o próprio usuário colou, sem precisar
tocar a conta real.
- [x] **Achado real #3** (usuário: "vira a mão quando está sobre a letra
e não sobre o retângulo inteiro"): o FAB flutuante é um botão
`rounded-full` de 56x56 — o Chrome exclui os "cantos cortados"
desse quadrado do hit-test de qualquer elemento com border-radius,
então passar o mouse num canto (fora do círculo visível, dentro do
quadrado que os olhos percebem como área clicável) cai pro `<div>`
de posicionamento por trás do botão (sem `cursor:pointer`),
mostrando seta em vez de mão — mesmo o clique dentro do círculo
real sempre tendo funcionado. Confirmado com Playwright: hover
real (`page.mouse.move` + `:hover` através do shadow DOM) nos 4
cantos/bordas do quadrado mostrava `cursor:auto` nos cantos e
`pointer` só no centro/ícone/bordas retas. Fix: `cursor-pointer`
no `<div>` wrapper (`Widget.jsx`) que envolve o FAB.
--- ---
## Riscos conhecidos ## Riscos conhecidos

View File

@@ -1,5 +1,13 @@
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator"; import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator";
/** Codecs de áudio suportados pelo perfil Sofia `internal` (mesma lista
* usada em `packages/telephony/src/directory-xml.ts::absolute_codec_string`
* — manter as duas em sincronia). */
export const AVAILABLE_CODECS = ["PCMU", "PCMA", "OPUS", "G722", "G729", "GSM"] as const;
const CODEC_ALTERNATION = AVAILABLE_CODECS.join("|");
const CODECS_REGEX = new RegExp(`^(${CODEC_ALTERNATION})(,(${CODEC_ALTERNATION}))*$`);
export class CreateExtensionDto { export class CreateExtensionDto {
@IsString() @IsString()
@Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" }) @Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" })
@@ -45,6 +53,11 @@ export class CreateExtensionDto {
} }
export class UpdateExtensionDto { export class UpdateExtensionDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(80) @MaxLength(80)
@@ -55,6 +68,16 @@ export class UpdateExtensionDto {
@Matches(/^[0-9]{2,20}$/) @Matches(/^[0-9]{2,20}$/)
callerIdNumber?: string; callerIdNumber?: string;
@IsOptional()
@IsString()
@MaxLength(80)
context?: string;
@IsOptional()
@IsString()
@Matches(CODECS_REGEX, { message: `codecs deve ser uma lista separada por vírgula entre: ${AVAILABLE_CODECS.join(", ")}` })
codecs?: string;
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(1) @Min(1)

View File

@@ -209,8 +209,11 @@ export class ExtensionsController {
tx.extension.updateMany({ tx.extension.updateMany({
where: { id, tenantId, deletedAt: null }, where: { id, tenantId, deletedAt: null },
data: { data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}), ...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}),
...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}), ...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}),
...(dto.context !== undefined ? { context: dto.context } : {}),
...(dto.codecs !== undefined ? { codecs: dto.codecs } : {}),
...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}), ...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}),
...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}), ...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}),
}, },

View File

@@ -3,10 +3,18 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsStri
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony"; import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator"; import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const;
export class IvrMenuOptionDto { export class IvrMenuOptionDto {
@IsIn(ALLOWED_IVR_DIGITS) @IsIn(ALLOWED_IVR_DIGITS)
digit!: string; digit!: string;
// PHASE 67 — decide como destinationNumber/destinationContext são
// interpretados (ver comentário em IvrMenuOption no schema).
@IsOptional()
@IsIn(IVR_OPTION_DESTINATION_TYPES)
destinationType?: (typeof IVR_OPTION_DESTINATION_TYPES)[number];
@IsString() @IsString()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" }) @Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
destinationNumber!: string; destinationNumber!: string;

View File

@@ -49,6 +49,53 @@ function promptHostPath(tenantId: string, menuId: string): string {
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`); return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`);
} }
/**
* PHASE 67 — destino "Outro IVR" cria um grafo entre menus (algo que
* InboundRoute->IVR nunca tinha, já que uma rota de entrada não é ela
* mesma um menu). Sem essa checagem, dois menus apontando um pro outro
* (ou um menu apontando pra si mesmo) compilam sem erro nenhum e só
* travam a experiência do CHAMADOR depois (fica indo de um prompt pro
* outro pra sempre) — pega isso antes de compilar, não depois de alguém
* reclamar. Monta o grafo com TODOS os menus do tenant (não só os que já
* existiam antes desta operação), substituindo as arestas do menu sendo
* salvo pelas opções novas — cobre tanto create quanto update.
*/
async function assertNoIvrCycle(
tx: Prisma.TransactionClient,
tenantId: string,
menuContext: string,
newOptions: IvrMenuOptionInput[],
): Promise<void> {
const menus = await tx.ivrMenu.findMany({
where: { tenantId, deletedAt: null },
select: { context: true, options: { select: { destinationType: true, destinationContext: true } } },
});
const edges = new Map<string, string[]>();
for (const m of menus) {
edges.set(
m.context,
m.options.filter((o) => o.destinationType === "IVR").map((o) => o.destinationContext),
);
}
edges.set(
menuContext,
newOptions.filter((o) => o.destinationType === "IVR").map((o) => o.destinationContext),
);
const visited = new Set<string>();
const stack = [...(edges.get(menuContext) ?? [])];
while (stack.length > 0) {
const next = stack.pop()!;
if (next === menuContext) {
throw new ConflictException(`Esse destino criaria um ciclo entre menus de IVR (via "${next}")`);
}
if (visited.has(next)) continue;
visited.add(next);
stack.push(...(edges.get(next) ?? []));
}
}
/** /**
* Tela de autoria de IVR (PHASE 58, docs/INBOUND_ROUTES.md) — por cima do * 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` * editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
@@ -133,22 +180,31 @@ export class IvrMenusController {
const prisma = getPrismaClient(); const prisma = getPrismaClient();
const tenantId = user.tenantId!; const tenantId = user.tenantId!;
const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType ?? "EXTENSION",
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext ?? "default",
}));
let menu; let menu;
try { try {
menu = await withTenantContext(prisma, tenantId, async (tx) => { menu = await withTenantContext(prisma, tenantId, async (tx) => {
await assertNoIvrCycle(tx, tenantId, dto.context, createOptions);
const created = await tx.ivrMenu.create({ const created = await tx.ivrMenu.create({
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting }, data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
}); });
await tx.ivrMenuOption.createMany({ await tx.ivrMenuOption.createMany({
data: dto.options.map((opt) => ({ data: createOptions.map((opt, i) => ({
tenantId, tenantId,
ivrMenuId: created.id, ivrMenuId: created.id,
digit: opt.digit, digit: opt.digit,
destinationType: opt.destinationType,
destinationNumber: opt.destinationNumber, destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default", destinationContext: opt.destinationContext,
label: opt.label, label: dto.options[i].label,
positionX: opt.positionX, positionX: dto.options[i].positionX,
positionY: opt.positionY, positionY: dto.options[i].positionY,
})), })),
}); });
return created; return created;
@@ -160,11 +216,6 @@ export class IvrMenusController {
throw err; 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 compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, createOptions);
await recordAuditEvent(prisma, { await recordAuditEvent(prisma, {
@@ -219,7 +270,24 @@ export class IvrMenusController {
); );
if (!existing) throw new NotFoundException(); if (!existing) throw new NotFoundException();
const options: IvrMenuOptionInput[] = dto.options
? dto.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType ?? "EXTENSION",
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext ?? "default",
}))
: existing.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));
const menu = await withTenantContext(prisma, tenantId, async (tx) => { const menu = await withTenantContext(prisma, tenantId, async (tx) => {
if (dto.options) {
await assertNoIvrCycle(tx, tenantId, existing.context, options);
}
const updated = await tx.ivrMenu.update({ const updated = await tx.ivrMenu.update({
where: { id }, where: { id },
data: { data: {
@@ -232,12 +300,13 @@ export class IvrMenusController {
if (dto.options) { if (dto.options) {
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } }); await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
await tx.ivrMenuOption.createMany({ await tx.ivrMenuOption.createMany({
data: dto.options.map((opt) => ({ data: dto.options.map((opt, i) => ({
tenantId, tenantId,
ivrMenuId: id, ivrMenuId: id,
digit: opt.digit, digit: opt.digit,
destinationType: options[i].destinationType,
destinationNumber: opt.destinationNumber, destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default", destinationContext: options[i].destinationContext,
label: opt.label, label: opt.label,
positionX: opt.positionX, positionX: opt.positionX,
positionY: opt.positionY, positionY: opt.positionY,
@@ -247,10 +316,6 @@ export class IvrMenusController {
return updated; 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 compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, options);
await recordAuditEvent(prisma, { await recordAuditEvent(prisma, {
@@ -342,6 +407,7 @@ export class IvrMenusController {
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({ const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit, digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber, destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext, destinationContext: o.destinationContext,
})); }));
@@ -380,6 +446,7 @@ export class IvrMenusController {
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({ const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit, digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber, destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext, destinationContext: o.destinationContext,
})); }));

View File

@@ -78,6 +78,7 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
tenantId: extension.tenantId, tenantId: extension.tenantId,
extensionId: extension.id, extensionId: extension.id,
callGroup: extension.callGroup, callGroup: extension.callGroup,
codecs: extension.codecs,
}); });
} }

File diff suppressed because one or more lines are too long

View File

@@ -29,7 +29,12 @@ export async function POST(request: Request) {
sameSite: "lax", sameSite: "lax",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
path: "/", path: "/",
maxAge: 60 * 60 * 8, // Call center: turno de agente pode passar de 8h — 12h é o teto
// absoluto do cookie mesmo com o refresh silencioso do middleware
// deslizando ele pra frente a cada requisição (o mesmo valor está
// duplicado em post-login/route.ts, select-tenant/actions.ts e
// middleware.ts — os 4 precisam ficar em sincronia).
maxAge: 60 * 60 * 12,
}); });
return response; return response;
} }

View File

@@ -46,7 +46,7 @@ export async function POST() {
sameSite: "lax", sameSite: "lax",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
path: "/", path: "/",
maxAge: 60 * 60 * 8, maxAge: 60 * 60 * 12,
}); });
return response; return response;
} }

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session"; import { requireSession } from "@/lib/session";
import { apiFetch, ApiError, API_BASE_URL } from "@/lib/api"; import { apiFetch, ApiError, API_BASE_URL } from "@/lib/api";
import type { IvrMenu } from "@/lib/callcenter-types"; import type { IvrMenu, IvrOptionDestinationType } from "@/lib/callcenter-types";
function extractErrorMessage(err: unknown): string { function extractErrorMessage(err: unknown): string {
if (err instanceof ApiError) { if (err instanceof ApiError) {
@@ -21,7 +21,9 @@ function extractErrorMessage(err: unknown): string {
export interface IvrMenuOptionInput { export interface IvrMenuOptionInput {
digit: string; digit: string;
destinationType?: IvrOptionDestinationType;
destinationNumber: string; destinationNumber: string;
destinationContext?: string;
label?: string; label?: string;
positionX?: number; positionX?: number;
positionY?: number; positionY?: number;

View File

@@ -19,14 +19,23 @@ import "@xyflow/react/dist/style.css";
import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react"; import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input, Select } from "@/components/ui/input"; import { Input, Select } from "@/components/ui/input";
import { ALLOWED_IVR_DIGITS, type IvrMenu } from "@/lib/callcenter-types"; import {
ALLOWED_IVR_DIGITS,
IVR_ENTRY_DESTINATION,
IVR_OPTION_DESTINATION_TYPES,
IVR_OPTION_DESTINATION_TYPE_LABELS,
type IvrMenu,
type IvrOptionDestinationType,
type Queue,
} from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types"; import type { Extension } from "@/lib/extension-types";
import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions"; import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions";
interface OptionState { interface OptionState {
key: string; key: string;
digit: string; digit: string;
destinationNumber: string; destinationType: IvrOptionDestinationType;
destinationValue: string;
label: string; label: string;
positionX: number | null; positionX: number | null;
positionY: number | null; positionY: number | null;
@@ -37,6 +46,8 @@ interface EntryNodeData extends Record<string, unknown> {}
interface OptionNodeData extends Record<string, unknown> { interface OptionNodeData extends Record<string, unknown> {
option: OptionState; option: OptionState;
extensions: Extension[]; extensions: Extension[];
queues: Queue[];
menus: IvrMenu[];
usedDigits: string[]; usedDigits: string[];
onChange: (key: string, patch: Partial<OptionState>) => void; onChange: (key: string, patch: Partial<OptionState>) => void;
onRemove: (key: string) => void; onRemove: (key: string) => void;
@@ -56,7 +67,7 @@ function EntryNode(_props: NodeProps<Node<EntryNodeData, "entry">>) {
} }
function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) { function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
const { option, extensions, usedDigits, onChange, onRemove } = data; const { option, extensions, queues, menus, usedDigits, onChange, onRemove } = data;
return ( return (
<div className="w-[230px] space-y-2 rounded-lg border border-border bg-surface px-3 py-3 shadow-panel"> <div className="w-[230px] space-y-2 rounded-lg border border-border bg-surface px-3 py-3 shadow-panel">
<Handle type="target" position={Position.Top} /> <Handle type="target" position={Position.Top} />
@@ -85,16 +96,40 @@ function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
</div> </div>
<Select <Select
className="nodrag" className="nodrag"
value={option.destinationNumber} value={option.destinationType}
onChange={(e) => onChange(option.key, { destinationNumber: e.target.value })} onChange={(e) => onChange(option.key, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
> >
<option value="">Selecione um ramal</option> {IVR_OPTION_DESTINATION_TYPES.map((t) => (
{extensions.map((ext) => ( <option key={t} value={t}>
<option key={ext.id} value={ext.number}> {IVR_OPTION_DESTINATION_TYPE_LABELS[t]}
{ext.number} {ext.name}
</option> </option>
))} ))}
</Select> </Select>
<Select
className="nodrag"
value={option.destinationValue}
onChange={(e) => onChange(option.key, { destinationValue: e.target.value })}
>
<option value="">Selecione</option>
{option.destinationType === "EXTENSION" &&
extensions.map((ext) => (
<option key={ext.id} value={ext.number}>
{ext.number} {ext.name}
</option>
))}
{option.destinationType === "QUEUE" &&
queues.map((q) => (
<option key={q.id} value={q.id}>
{q.name}
</option>
))}
{option.destinationType === "IVR" &&
menus.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select>
<Input <Input
className="nodrag" className="nodrag"
value={option.label} value={option.label}
@@ -114,6 +149,8 @@ function layoutNodes(
options: OptionState[], options: OptionState[],
entryPosition: { x: number; y: number } | null, entryPosition: { x: number; y: number } | null,
extensions: Extension[], extensions: Extension[],
queues: Queue[],
menus: IvrMenu[],
onChange: OptionNodeData["onChange"], onChange: OptionNodeData["onChange"],
onRemove: OptionNodeData["onRemove"], onRemove: OptionNodeData["onRemove"],
) { ) {
@@ -129,7 +166,7 @@ function layoutNodes(
id: option.key, id: option.key,
type: "option", type: "option",
position: option.positionX != null && option.positionY != null ? { x: option.positionX, y: option.positionY } : { x: i * COLUMN_WIDTH, y: 160 }, position: option.positionX != null && option.positionY != null ? { x: option.positionX, y: option.positionY } : { x: i * COLUMN_WIDTH, y: 160 },
data: { option, extensions, usedDigits, onChange, onRemove }, data: { option, extensions, queues, menus, usedDigits, onChange, onRemove },
draggable: true, draggable: true,
})); }));
const edges: Edge[] = options.map((option) => ({ const edges: Edge[] = options.map((option) => ({
@@ -155,18 +192,29 @@ function nextKey(): string {
* direto os campos (dígito/ramal/descrição); salvar recompila o * direto os campos (dígito/ramal/descrição); salvar recompila o
* dialplan no backend exatamente como o formulário antigo fazia. * dialplan no backend exatamente como o formulário antigo fazia.
*/ */
export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: Extension[] }) { export function IvrFlowEditor({
menu,
extensions,
queues,
menus,
}: {
menu: IvrMenu;
extensions: Extension[];
queues: Queue[];
menus: IvrMenu[];
}) {
const initialOptions = useMemo<OptionState[]>( const initialOptions = useMemo<OptionState[]>(
() => () =>
menu.options.map((o) => ({ menu.options.map((o) => ({
key: o.id, key: o.id,
digit: o.digit, digit: o.digit,
destinationNumber: o.destinationNumber, destinationType: o.destinationType,
destinationValue: o.destinationType === "IVR" ? (menus.find((m) => m.context === o.destinationContext)?.id ?? "") : o.destinationNumber,
label: o.label ?? "", label: o.label ?? "",
positionX: o.positionX, positionX: o.positionX,
positionY: o.positionY, positionY: o.positionY,
})), })),
[menu.options], [menu.options, menus],
); );
const initialEntryPosition = useMemo( const initialEntryPosition = useMemo(
() => (menu.entryPositionX != null && menu.entryPositionY != null ? { x: menu.entryPositionX, y: menu.entryPositionY } : null), () => (menu.entryPositionX != null && menu.entryPositionY != null ? { x: menu.entryPositionX, y: menu.entryPositionY } : null),
@@ -188,9 +236,9 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
}, []); }, []);
const { nodes: initialNodes, edges: initialEdges } = useMemo( const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => layoutNodes(options, initialEntryPosition, extensions, onChange, onRemove), () => layoutNodes(options, initialEntryPosition, extensions, queues, menus, onChange, onRemove),
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialEntryPosition só serve pro layout inicial (posição arrastada depois vive em `nodes`, nunca recomputada daqui) // eslint-disable-next-line react-hooks/exhaustive-deps -- initialEntryPosition só serve pro layout inicial (posição arrastada depois vive em `nodes`, nunca recomputada daqui)
[options, extensions, onChange, onRemove], [options, extensions, queues, menus, onChange, onRemove],
); );
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
@@ -212,7 +260,10 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
setSaved(false); setSaved(false);
const usedDigits = new Set(options.map((o) => o.digit)); const usedDigits = new Set(options.map((o) => o.digit));
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1"; const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
setOptions((prev) => [...prev, { key: nextKey(), digit: nextDigit, destinationNumber: "", label: "", positionX: null, positionY: null }]); setOptions((prev) => [
...prev,
{ key: nextKey(), digit: nextDigit, destinationType: "EXTENSION", destinationValue: "", label: "", positionX: null, positionY: null },
]);
} }
function onSave() { function onSave() {
@@ -221,8 +272,8 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
setError("O menu precisa de pelo menos uma opção."); setError("O menu precisa de pelo menos uma opção.");
return; return;
} }
if (options.some((o) => !o.destinationNumber.trim())) { if (options.some((o) => !o.destinationValue.trim())) {
setError("Toda opção precisa de um ramal de destino selecionado."); setError("Toda opção precisa de um destino selecionado.");
return; return;
} }
const digits = options.map((o) => o.digit); const digits = options.map((o) => o.digit);
@@ -236,12 +287,24 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
// pelo react-flow é o estado de nós, nunca `data.option` de volta. // pelo react-flow é o estado de nós, nunca `data.option` de volta.
const payload: IvrMenuOptionInput[] = options.map((o) => { const payload: IvrMenuOptionInput[] = options.map((o) => {
const node = nodes.find((n) => n.id === o.key); const node = nodes.find((n) => n.id === o.key);
const position = { positionX: node?.position.x, positionY: node?.position.y };
if (o.destinationType === "IVR") {
const target = menus.find((m) => m.id === o.destinationValue);
return {
digit: o.digit,
destinationType: "IVR",
destinationNumber: IVR_ENTRY_DESTINATION,
destinationContext: target?.context ?? "default",
label: o.label.trim() || undefined,
...position,
};
}
return { return {
digit: o.digit, digit: o.digit,
destinationNumber: o.destinationNumber.trim(), destinationType: o.destinationType,
destinationNumber: o.destinationValue.trim(),
label: o.label.trim() || undefined, label: o.label.trim() || undefined,
positionX: node?.position.x, ...position,
positionY: node?.position.y,
}; };
}); });
const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID); const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID);

View File

@@ -8,7 +8,15 @@ import { Button } from "@/components/ui/button";
import { Input, Select, FieldLabel } from "@/components/ui/input"; import { Input, Select, FieldLabel } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill"; import { Pill } from "@/components/ui/pill";
import { EmptyState } from "@/components/ui/table"; import { EmptyState } from "@/components/ui/table";
import { ALLOWED_IVR_DIGITS, IVR_ENTRY_DESTINATION, type IvrMenu } from "@/lib/callcenter-types"; import {
ALLOWED_IVR_DIGITS,
IVR_ENTRY_DESTINATION,
IVR_OPTION_DESTINATION_TYPES,
IVR_OPTION_DESTINATION_TYPE_LABELS,
type IvrMenu,
type IvrOptionDestinationType,
type Queue,
} from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types"; import type { Extension } from "@/lib/extension-types";
import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions"; import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
import { IvrFlowEditor } from "./ivr-flow-editor"; import { IvrFlowEditor } from "./ivr-flow-editor";
@@ -27,7 +35,7 @@ function slugifyContext(name: string): string {
); );
} }
export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: Extension[] }) { export function IvrView({ menus, extensions, queues }: { menus: IvrMenu[]; extensions: Extension[]; queues: Queue[] }) {
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
return ( return (
@@ -47,7 +55,15 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
</Button> </Button>
</div> </div>
{showForm && <NewIvrMenuForm extensions={extensions} existingContexts={menus.map((m) => m.context)} onDone={() => setShowForm(false)} />} {showForm && (
<NewIvrMenuForm
extensions={extensions}
queues={queues}
menus={menus}
existingContexts={menus.map((m) => m.context)}
onDone={() => setShowForm(false)}
/>
)}
<Panel> <Panel>
<PanelHeader title="Menus cadastrados" description={`${menus.length} menu(s) neste tenant`} /> <PanelHeader title="Menus cadastrados" description={`${menus.length} menu(s) neste tenant`} />
@@ -70,7 +86,12 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span> <span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
</p> </p>
<PromptControl menu={menu} /> <PromptControl menu={menu} />
<IvrFlowEditor menu={menu} extensions={extensions} /> <IvrFlowEditor
menu={menu}
extensions={extensions}
queues={queues}
menus={menus.filter((m) => m.id !== menu.id)}
/>
</li> </li>
))} ))}
</ul> </ul>
@@ -143,23 +164,28 @@ function PromptControl({ menu }: { menu: IvrMenu }) {
interface OptionRow { interface OptionRow {
digit: string; digit: string;
destinationNumber: string; destinationType: IvrOptionDestinationType;
destinationValue: string;
label: string; label: string;
} }
function NewIvrMenuForm({ function NewIvrMenuForm({
extensions, extensions,
queues,
menus,
existingContexts, existingContexts,
onDone, onDone,
}: { }: {
extensions: Extension[]; extensions: Extension[];
queues: Queue[];
menus: IvrMenu[];
existingContexts: string[]; existingContexts: string[];
onDone: () => void; onDone: () => void;
}) { }) {
const [name, setName] = useState(""); const [name, setName] = useState("");
const [context, setContext] = useState(""); const [context, setContext] = useState("");
const [contextTouched, setContextTouched] = useState(false); const [contextTouched, setContextTouched] = useState(false);
const [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationNumber: "", label: "" }]); const [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationType: "EXTENSION", destinationValue: "", label: "" }]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -172,7 +198,7 @@ function NewIvrMenuForm({
function addOption() { function addOption() {
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1"; const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
setOptions((prev) => [...prev, { digit: nextDigit, destinationNumber: "", label: "" }]); setOptions((prev) => [...prev, { digit: nextDigit, destinationType: "EXTENSION", destinationValue: "", label: "" }]);
} }
function removeOption(index: number) { function removeOption(index: number) {
@@ -190,8 +216,8 @@ function NewIvrMenuForm({
setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`); setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`);
return; return;
} }
if (options.length === 0 || options.some((o) => !o.destinationNumber.trim())) { if (options.length === 0 || options.some((o) => !o.destinationValue.trim())) {
setError("Toda opção precisa de um dígito e um ramal de destino."); setError("Toda opção precisa de um dígito e um destino selecionado.");
return; return;
} }
const digits = options.map((o) => o.digit); const digits = options.map((o) => o.digit);
@@ -200,11 +226,24 @@ function NewIvrMenuForm({
return; return;
} }
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => ({ const payloadOptions: IvrMenuOptionInput[] = options.map((o) => {
digit: o.digit, if (o.destinationType === "IVR") {
destinationNumber: o.destinationNumber.trim(), const target = menus.find((m) => m.id === o.destinationValue);
label: o.label.trim() || undefined, return {
})); digit: o.digit,
destinationType: "IVR",
destinationNumber: IVR_ENTRY_DESTINATION,
destinationContext: target?.context ?? "default",
label: o.label.trim() || undefined,
};
}
return {
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationValue.trim(),
label: o.label.trim() || undefined,
};
});
startTransition(async () => { startTransition(async () => {
const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions }); const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions });
@@ -247,7 +286,7 @@ function NewIvrMenuForm({
</Button> </Button>
</div> </div>
{options.map((opt, i) => ( {options.map((opt, i) => (
<div key={i} className="grid grid-cols-1 gap-3 rounded-md border border-border p-3 sm:grid-cols-[6rem_1fr_1fr_auto]"> <div key={i} className="grid grid-cols-1 gap-3 rounded-md border border-border p-3 sm:grid-cols-[5rem_7rem_1fr_1fr_auto]">
<div> <div>
<FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel> <FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel>
<Select <Select
@@ -264,21 +303,52 @@ function NewIvrMenuForm({
</Select> </Select>
</div> </div>
<div> <div>
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Ramal de destino</FieldLabel> <FieldLabel htmlFor={`ivr-opt-${i}-type`}>Tipo</FieldLabel>
<Select <Select
id={`ivr-opt-${i}-dest`} id={`ivr-opt-${i}-type`}
value={opt.destinationNumber} value={opt.destinationType}
onChange={(e) => updateOption(i, { destinationNumber: e.target.value })} onChange={(e) => updateOption(i, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
disabled={pending} disabled={pending}
> >
<option value="">Selecione um ramal</option> {IVR_OPTION_DESTINATION_TYPES.map((t) => (
{extensions.map((ext) => ( <option key={t} value={t}>
<option key={ext.id} value={ext.number}> {IVR_OPTION_DESTINATION_TYPE_LABELS[t]}
{ext.number} {ext.name}
</option> </option>
))} ))}
</Select> </Select>
</div> </div>
<div>
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Destino</FieldLabel>
<Select
id={`ivr-opt-${i}-dest`}
value={opt.destinationValue}
onChange={(e) => updateOption(i, { destinationValue: e.target.value })}
disabled={pending}
>
<option value="">Selecione</option>
{opt.destinationType === "EXTENSION" &&
extensions.map((ext) => (
<option key={ext.id} value={ext.number}>
{ext.number} {ext.name}
</option>
))}
{opt.destinationType === "QUEUE" &&
queues.map((q) => (
<option key={q.id} value={q.id}>
{q.name}
</option>
))}
{opt.destinationType === "IVR" &&
menus.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select>
{opt.destinationType === "IVR" && menus.length === 0 && (
<p className="mt-1 text-xs text-muted-foreground">Crie outro menu de IVR primeiro pra poder apontar pra ele.</p>
)}
</div>
<div> <div>
<FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel> <FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel>
<Input <Input

View File

@@ -1,14 +1,15 @@
import { requireSession } from "@/lib/session"; import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import type { IvrMenu } from "@/lib/callcenter-types"; import type { IvrMenu, Queue } from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types"; import type { Extension } from "@/lib/extension-types";
import { IvrView } from "./ivr-view"; import { IvrView } from "./ivr-view";
export default async function IvrPage() { export default async function IvrPage() {
const session = await requireSession(); const session = await requireSession();
const [menus, extensions] = await Promise.all([ const [menus, extensions, queues] = await Promise.all([
apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken), apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken),
apiFetch<Extension[]>("/extensions", session.accessToken), apiFetch<Extension[]>("/extensions", session.accessToken),
apiFetch<Queue[]>("/queues", session.accessToken),
]); ]);
return <IvrView menus={menus} extensions={extensions} />; return <IvrView menus={menus} extensions={extensions} queues={queues} />;
} }

View File

@@ -2,12 +2,198 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Eye, KeyRound, Trash2 } from "lucide-react"; import { Eye, KeyRound, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input, FieldLabel } from "@/components/ui/input";
import { SecretReveal } from "@/components/ui/secret-reveal"; import { SecretReveal } from "@/components/ui/secret-reveal";
import { AVAILABLE_CODECS, type Extension } from "@/lib/extension-types";
import { resetExtensionPassword, revealExtensionPassword, deleteExtension, updateExtension } from "../actions"; import { resetExtensionPassword, revealExtensionPassword, deleteExtension, updateExtension } from "../actions";
/** Formulário único de edição (nome, caller ID, contexto, codecs, grupo
* de captura) — pedido do usuário: "quando clicar no ramal para editar a
* senha ou ver a senha atual já abra pra alterar" o resto da configuração
* junto, num só lugar com um botão de salvar. */
export function ExtensionEditForm({ extension, existingCallGroups }: { extension: Extension; existingCallGroups: string[] }) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [name, setName] = useState(extension.name);
const [callerIdName, setCallerIdName] = useState(extension.callerIdName ?? "");
const [callerIdNumber, setCallerIdNumber] = useState(extension.callerIdNumber ?? "");
const [context, setContext] = useState(extension.context);
const [codecs, setCodecs] = useState<string[]>(extension.codecs.split(",").map((c) => c.trim()).filter(Boolean));
const [callGroup, setCallGroup] = useState(extension.callGroup ?? "");
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function toggleCodec(codec: string) {
setCodecs((prev) => (prev.includes(codec) ? prev.filter((c) => c !== codec) : [...prev, codec]));
}
function onSave() {
setError(null);
if (!name.trim()) {
setError("Dê um nome ao ramal.");
return;
}
if (codecs.length === 0) {
setError("Selecione ao menos um codec.");
return;
}
startTransition(async () => {
const result = await updateExtension(extension.id, {
name: name.trim(),
callerIdName: callerIdName.trim() || null,
callerIdNumber: callerIdNumber.trim() || null,
context: context.trim() || undefined,
codecs: codecs.join(","),
callGroup: callGroup.trim() || null,
});
if (!result.ok) {
setError(result.error);
return;
}
setEditing(false);
router.refresh();
});
}
function onCancel() {
setName(extension.name);
setCallerIdName(extension.callerIdName ?? "");
setCallerIdNumber(extension.callerIdNumber ?? "");
setContext(extension.context);
setCodecs(extension.codecs.split(",").map((c) => c.trim()).filter(Boolean));
setCallGroup(extension.callGroup ?? "");
setError(null);
setEditing(false);
}
if (!editing) {
return (
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Nome</dt>
<dd className="mt-1 text-sm text-foreground">{extension.name}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Caller ID</dt>
<dd className="mt-1 text-sm text-foreground">
{extension.callerIdName || extension.callerIdNumber
? `${extension.callerIdName ?? ""} ${extension.callerIdNumber ? `<${extension.callerIdNumber}>` : ""}`.trim()
: "—"}
</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Contexto</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.context}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Perfil Sofia</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.sofiaProfile}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Codecs</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.codecs}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Grupo de captura</dt>
<dd className="mt-1 text-sm text-foreground">{extension.callGroup ?? "— (nenhum)"}</dd>
</div>
<div className="sm:col-span-2">
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="h-4 w-4" aria-hidden />
Editar configuração
</Button>
</div>
</dl>
);
}
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<FieldLabel htmlFor="ext-edit-name">Nome</FieldLabel>
<Input id="ext-edit-name" value={name} onChange={(e) => setName(e.target.value)} disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="ext-edit-context">Contexto</FieldLabel>
<Input id="ext-edit-context" value={context} onChange={(e) => setContext(e.target.value)} className="font-mono" disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="ext-edit-cid-name">Caller ID nome</FieldLabel>
<Input id="ext-edit-cid-name" value={callerIdName} onChange={(e) => setCallerIdName(e.target.value)} disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="ext-edit-cid-number">Caller ID número</FieldLabel>
<Input
id="ext-edit-cid-number"
value={callerIdNumber}
onChange={(e) => setCallerIdNumber(e.target.value.replace(/[^0-9]/g, ""))}
className="font-mono"
disabled={pending}
/>
</div>
</div>
<div>
<FieldLabel>Codecs disponíveis</FieldLabel>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{AVAILABLE_CODECS.map((codec) => (
<label key={codec} className="flex items-center gap-1.5 text-sm text-foreground">
<input
type="checkbox"
checked={codecs.includes(codec)}
onChange={() => toggleCodec(codec)}
disabled={pending}
className="h-4 w-4 rounded border-input"
/>
<span className="font-mono">{codec}</span>
</label>
))}
</div>
</div>
<div className="sm:w-72">
<FieldLabel htmlFor="ext-edit-callgroup">Grupo de captura</FieldLabel>
<Input
id="ext-edit-callgroup"
list="ext-edit-callgroups"
value={callGroup}
onChange={(e) => setCallGroup(e.target.value)}
placeholder="Sem grupo"
className="font-mono"
disabled={pending}
/>
<datalist id="ext-edit-callgroups">
{existingCallGroups.map((group) => (
<option key={group} value={group} />
))}
</datalist>
<p className="mt-1.5 text-xs text-muted-foreground">
Escolha um grupo existente na lista ou digite um novo. Ramais no mesmo grupo podem capturar a chamada um
do outro (*8).
</p>
</div>
{error && (
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="flex gap-3">
<Button type="button" onClick={onSave} disabled={pending}>
{pending ? "Salvando…" : "Salvar"}
</Button>
<Button type="button" variant="ghost" onClick={onCancel} disabled={pending}>
Cancelar
</Button>
</div>
</div>
);
}
/** Diferente de `ResetPasswordAction`: nunca troca a senha, só mostra a /** Diferente de `ResetPasswordAction`: nunca troca a senha, só mostra a
* atual de novo — pra reconfigurar um telefone/softphone sem invalidar * atual de novo — pra reconfigurar um telefone/softphone sem invalidar
* outro aparelho já usando a mesma credencial (achado real reportado pelo * outro aparelho já usando a mesma credencial (achado real reportado pelo
@@ -48,51 +234,6 @@ export function RevealPasswordAction({ extensionId }: { extensionId: string }) {
); );
} }
export function CallGroupEditAction({ extensionId, callGroup }: { extensionId: string; callGroup: string | null }) {
const router = useRouter();
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(callGroup ?? "");
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function onSave() {
setError(null);
startTransition(async () => {
const result = await updateExtension(extensionId, { callGroup: value.trim() || null });
if (!result.ok) {
setError(result.error);
return;
}
setEditing(false);
router.refresh();
});
}
if (!editing) {
return (
<div className="flex items-center gap-3">
<span className="font-mono text-sm text-foreground">{callGroup ?? "— (nenhum)"}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)}>
Editar
</Button>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Input value={value} onChange={(e) => setValue(e.target.value)} placeholder="Ex.: recepcao" className="h-8 w-48 font-mono" disabled={pending} />
<Button type="button" size="sm" onClick={onSave} disabled={pending}>
{pending ? "…" : "Salvar"}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)} disabled={pending}>
Cancelar
</Button>
{error && <span className="text-xs text-destructive">{error}</span>}
</div>
);
}
export function ResetPasswordAction({ extensionId }: { extensionId: string }) { export function ResetPasswordAction({ extensionId }: { extensionId: string }) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);

View File

@@ -7,7 +7,7 @@ import { Panel, PanelHeader } from "@/components/ui/panel";
import { Pill } from "@/components/ui/pill"; import { Pill } from "@/components/ui/pill";
import { formatDate } from "@/lib/format"; import { formatDate } from "@/lib/format";
import type { Extension } from "@/lib/extension-types"; import type { Extension } from "@/lib/extension-types";
import { ResetPasswordAction, RevealPasswordAction, CallGroupEditAction, DeleteExtensionAction } from "./actions-panel"; import { ResetPasswordAction, RevealPasswordAction, ExtensionEditForm, DeleteExtensionAction } from "./actions-panel";
interface Me { interface Me {
tenant: { name: string; code: string } | null; tenant: { name: string; code: string } | null;
@@ -25,6 +25,8 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
throw err; throw err;
} }
const me = await apiFetch<Me>("/auth/me", session.accessToken); const me = await apiFetch<Me>("/auth/me", session.accessToken);
const allExtensions = await apiFetch<Extension[]>("/extensions", session.accessToken);
const existingCallGroups = [...new Set(allExtensions.map((e) => e.callGroup).filter((g): g is string => !!g))].sort();
return ( return (
<div className="mx-auto max-w-2xl space-y-5"> <div className="mx-auto max-w-2xl space-y-5">
@@ -46,54 +48,25 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
<span className="font-mono">{extension.domain}</span> <span className="font-mono">{extension.domain}</span>
</p> </p>
<Panel>
<PanelHeader title="Configuração" />
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 p-5 sm:grid-cols-2">
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Caller ID</dt>
<dd className="mt-1 text-sm text-foreground">
{extension.callerIdName || extension.callerIdNumber
? `${extension.callerIdName ?? ""} ${extension.callerIdNumber ? `<${extension.callerIdNumber}>` : ""}`.trim()
: "—"}
</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Contexto</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.context}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Perfil Sofia</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.sofiaProfile}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Codecs</dt>
<dd className="mt-1 font-mono text-sm text-foreground">{extension.codecs}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Registros simultâneos</dt>
<dd className="mt-1 font-mono text-sm tabular-nums text-foreground">{extension.maxRegistrations}</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Grupo de captura</dt>
<dd className="mt-1 text-sm text-foreground">
<CallGroupEditAction extensionId={extension.id} callGroup={extension.callGroup} />
</dd>
</div>
<div>
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Criado em</dt>
<dd className="mt-1 text-sm text-foreground">{formatDate(extension.createdAt)}</dd>
</div>
</dl>
</Panel>
<Panel> <Panel>
<PanelHeader <PanelHeader
title="Senha SIP" title="Configuração e senha SIP"
description="Cifrada em repouso — dá pra ver a atual de novo (pra reconfigurar um aparelho) ou gerar uma nova." description="Edite os dados do ramal e veja/redefina a senha SIP no mesmo lugar."
/> />
<div className="space-y-4 p-5"> <div className="space-y-5 p-5">
<RevealPasswordAction extensionId={extension.id} /> <ExtensionEditForm extension={extension} existingCallGroups={existingCallGroups} />
<ResetPasswordAction extensionId={extension.id} /> <div className="flex flex-wrap gap-x-6 gap-y-1 border-t border-border pt-4 text-xs text-muted-foreground">
<span>
Registros simultâneos: <span className="font-mono tabular-nums text-foreground">{extension.maxRegistrations}</span>
</span>
<span>Criado em {formatDate(extension.createdAt)}</span>
</div>
<div className="border-t border-border pt-4">
<div className="space-y-4">
<RevealPasswordAction extensionId={extension.id} />
<ResetPasswordAction extensionId={extension.id} />
</div>
</div>
</div> </div>
</Panel> </Panel>

View File

@@ -74,8 +74,11 @@ export async function revealExtensionPassword(id: string): Promise<{ ok: true; s
} }
export interface UpdateExtensionInput { export interface UpdateExtensionInput {
callerIdName?: string; name?: string;
callerIdNumber?: string; callerIdName?: string | null;
callerIdNumber?: string | null;
context?: string;
codecs?: string;
maxRegistrations?: number; maxRegistrations?: number;
callGroup?: string | null; callGroup?: string | null;
} }

View File

@@ -26,7 +26,7 @@ export async function chooseTenant(tenantId: string): Promise<{ error: string }
sameSite: "lax", sameSite: "lax",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
path: "/", path: "/",
maxAge: 60 * 60 * 8, maxAge: 60 * 60 * 12,
}); });
redirect("/app"); redirect("/app");

View File

@@ -1,9 +1,36 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Copy, Check, TriangleAlert } from "lucide-react"; import { Copy, Check, TriangleAlert, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
/**
* `navigator.clipboard` só existe em "contexto seguro" (HTTPS ou
* localhost) — achado real reportado pelo usuário: o botão de copiar não
* fazia nada (sem erro nenhum, silencioso) quando acessado pelo IP da VM
* em HTTP puro (agente.md: ambiente de laboratório sem HTTPS ainda).
* `execCommand("copy")` sobre um textarea temporário fora da tela é o
* fallback clássico que funciona em qualquer contexto, inclusive HTTP.
*/
function legacyCopy(value: string): boolean {
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
document.body.removeChild(textarea);
return ok;
}
/** /**
* Mostra um segredo em texto puro UMA vez (senha SIP recém-gerada, * Mostra um segredo em texto puro UMA vez (senha SIP recém-gerada,
* agente.md secao 39: "nunca mostrar novamente a senha inteira" depois * agente.md secao 39: "nunca mostrar novamente a senha inteira" depois
@@ -12,11 +39,31 @@ import { cn } from "@/lib/utils";
*/ */
export function SecretReveal({ label, value }: { label: string; value: string }) { export function SecretReveal({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [error, setError] = useState(false);
async function onCopy() { async function onCopy() {
await navigator.clipboard.writeText(value); setError(false);
setCopied(true); let ok = false;
setTimeout(() => setCopied(false), 2000); if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
ok = true;
} catch {
ok = false;
}
}
if (!ok) ok = legacyCopy(value);
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
// Nenhuma das duas formas funcionou — o texto já está selecionável
// (`select-all` no <code> abaixo), avisa pra copiar manualmente
// (Ctrl+C) em vez de falhar em silêncio.
setError(true);
setTimeout(() => setError(false), 4000);
}
} }
return ( return (
@@ -35,9 +82,20 @@ export function SecretReveal({ label, value }: { label: string; value: string })
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Copiar senha" aria-label="Copiar senha"
> >
{copied ? <Check className="h-4 w-4 text-status-green" aria-hidden /> : <Copy className="h-4 w-4" aria-hidden />} {copied ? (
<Check className="h-4 w-4 text-status-green" aria-hidden />
) : error ? (
<X className="h-4 w-4 text-destructive" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button> </button>
</div> </div>
{error && (
<p className="mt-2 text-xs text-destructive">
Não deu pra copiar automaticamente selecione o texto acima e copie com Ctrl+C (ou Cmd+C).
</p>
)}
</div> </div>
); );
} }

View File

@@ -74,9 +74,19 @@ export const ALLOWED_IVR_DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9",
* aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */ * aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */
export const IVR_ENTRY_DESTINATION = "ivr_entry"; export const IVR_ENTRY_DESTINATION = "ivr_entry";
export const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const;
export type IvrOptionDestinationType = (typeof IVR_OPTION_DESTINATION_TYPES)[number];
export const IVR_OPTION_DESTINATION_TYPE_LABELS: Record<IvrOptionDestinationType, string> = {
EXTENSION: "Ramal",
QUEUE: "Fila",
IVR: "Outro IVR",
};
export interface IvrMenuOption { export interface IvrMenuOption {
id: string; id: string;
digit: string; digit: string;
destinationType: IvrOptionDestinationType;
destinationNumber: string; destinationNumber: string;
destinationContext: string; destinationContext: string;
label: string | null; label: string | null;

View File

@@ -1,3 +1,6 @@
/** Mesma lista de `apps/api/src/extensions/dto/create-extension.dto.ts::AVAILABLE_CODECS` — manter em sincronia. */
export const AVAILABLE_CODECS = ["PCMU", "PCMA", "OPUS", "G722", "G729", "GSM"] as const;
export interface Extension { export interface Extension {
id: string; id: string;
tenantId: string; tenantId: string;

View File

@@ -68,7 +68,7 @@ export async function middleware(request: NextRequest) {
sameSite: "lax", sameSite: "lax",
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
path: "/", path: "/",
maxAge: 60 * 60 * 8, maxAge: 60 * 60 * 12,
}); });
return response; return response;
} catch { } catch {

View File

@@ -49,11 +49,18 @@ services:
FS_CONFIG_USER: ${FS_CONFIG_USER} FS_CONFIG_USER: ${FS_CONFIG_USER}
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD} FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
ESL_HOST: freeswitch # host.docker.internal (PHASE 68): freeswitch virou network_mode:
# host, saiu da rede bridge do compose — o nome de serviço
# "freeswitch" não resolve mais pra ele. host.docker.internal
# aponta pro HOST (onde o ESL agora escuta direto), exige o
# extra_hosts abaixo em Linux (Docker 20.10+).
ESL_HOST: host.docker.internal
ESL_PORT: "8021" ESL_PORT: "8021"
ESL_PASSWORD: ${ESL_PASSWORD} ESL_PASSWORD: ${ESL_PASSWORD}
SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways
CALLCENTER_QUEUES_DIR: /callcenter-queues CALLCENTER_QUEUES_DIR: /callcenter-queues
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
# Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config # Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config
# escreve os XML de gateway aqui, o profile "external" ja inclui # escreve os XML de gateway aqui, o profile "external" ja inclui
@@ -63,7 +70,12 @@ services:
# escreve um XML de fila por arquivo aqui, incluido via # escreve um XML de fila por arquivo aqui, incluido via
# X-PRE-PROCESS no nosso callcenter.conf.xml (mesmo padrao acima). # X-PRE-PROCESS no nosso callcenter.conf.xml (mesmo padrao acima).
- freeswitch_callcenter_queues:/callcenter-queues - freeswitch_callcenter_queues:/callcenter-queues
# Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto. # Publicado só em loopback (PHASE 68): o FreeSWITCH virou
# network_mode: host e chama isto via 127.0.0.1 agora (ver
# xml_curl.conf.xml) — antes não precisava de porta nenhuma porque os
# dois estavam na mesma rede bridge do compose.
ports:
- "127.0.0.1:8080:8080"
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 10s interval: 10s
@@ -99,28 +111,31 @@ services:
# uma chamada ativa seria latência/confiabilidade desnecessárias # uma chamada ativa seria latência/confiabilidade desnecessárias
# pra um prompt de poucos segundos. # pra um prompt de poucos segundos.
- ./data/ivr-prompts:/ivr-prompts - ./data/ivr-prompts:/ivr-prompts
# Event Socket (8021) NUNCA publicado pra REDE — só em loopback do # network_mode: host (PHASE 68) — achado real reportado pelo usuário:
# próprio host (agente.md secao 22: "NÃO pública... acesso somente # dois ramais reais (telefones IP), atrás do MESMO roteador/NAT desta
# pela aplicação autorizada"). Achado real testando as telas de # rede (esta VM nunca tem IP público próprio — é sempre RFC1918 por
# Infraestrutura (platform): o comentário original aqui dizia que # trás do NAT do escritório), ligando um pro outro. Com bridge+NAT do
# elas SEMPRE falhariam nesta VM porque "apps/api roda fora do # Docker no meio, o SIP/RTP entre eles precisava atravessar uma camada
# Docker" — isso estava incompleto. `apps/api` (systemd, no host) # de tradução de endereço A MAIS antes mesmo de chegar no roteador do
# JÁ conseguia alcançar o IP do container na rede bridge sem nenhuma # escritório — combinado com o hairpin NAT do roteador (dois clientes
# porta publicada (`docker inspect` + `/dev/tcp` confirmam: o HOST # atrás do mesmo NAT se enxergando só pelo IP público), a chamada
# sempre alcança a rede bridge do Docker, só outras MÁQUINAS é que # falhava em 0ms (503, nunca chegava a sair pela rede de verdade).
# não). O bloqueio real era `ESL_HOST=freeswitch` — um nome DNS que # Tirar o Docker NAT do meio (host networking) elimina essa camada
# só existe dentro da rede interna do Docker, nunca no resolver do # extra; o fix real de hairpin em si é separado — ver comentário sobre
# host. `127.0.0.1:8021:8021` resolve isso sem abrir a porta pra # `NDLB-received-in-nat-reg-contact` no Dockerfile.
# ninguém além do próprio host — mesma garantia de segurança de #
# antes, só que a aplicação autorizada agora alcança de verdade. # Sem `ports:` aqui (inválido com network_mode: host — o container usa
# SIP (5060) e RTP (16384-16584, range fixo no Dockerfile) publicados # as portas do HOST diretamente). Segurança do Event Socket (8021,
# pra rede a partir da PHASE 54 — necessário pra registrar um # agente.md secao 22) deixa de depender de publicar só em loopback
# softphone/telefone de fora da rede Docker. # (não existe mais essa camada) e passa a depender inteiramente da
ports: # ACL `apply-inbound-acl="b2bcall_internal"` já configurada em
- "127.0.0.1:8021:8021" # event_socket.conf.xml (127.0.0.0/8 + 172.16.0.0/12, o range do
- "5060:5060/udp" # bridge do Docker) — qualquer outra origem continua rejeitada mesmo
- "5060:5060/tcp" # com a porta agora "aberta" nas interfaces do host. Os workers que
- "16384-16584:16384-16584/udp" # antes alcançavam via nome de serviço `freeswitch` na rede do compose
# (fs-events/fs-config/predictive-dialer) precisam agora de
# `host.docker.internal` — ver `extra_hosts` em cada um deles abaixo.
network_mode: host
healthcheck: healthcheck:
test: ["CMD-SHELL", "fs_cli -p \"$$ESL_PASSWORD\" -x status | grep -q 'is ready'"] test: ["CMD-SHELL", "fs_cli -p \"$$ESL_PASSWORD\" -x status | grep -q 'is ready'"]
interval: 10s interval: 10s
@@ -139,7 +154,9 @@ services:
- redis - redis
- postgres - postgres
environment: environment:
ESL_HOST: freeswitch # host.docker.internal, não "freeswitch" (PHASE 68) — ver comentário
# equivalente em fs-config acima.
ESL_HOST: host.docker.internal
ESL_PORT: "8021" ESL_PORT: "8021"
ESL_PASSWORD: ${ESL_PASSWORD} ESL_PASSWORD: ${ESL_PASSWORD}
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
@@ -159,6 +176,8 @@ services:
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-} S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-}
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
- ./data/recordings-spool:/recordings - ./data/recordings-spool:/recordings
- ./data/object-storage-local:/data/object-storage - ./data/object-storage-local:/data/object-storage
@@ -174,7 +193,9 @@ services:
- redis - redis
- postgres - postgres
environment: environment:
ESL_HOST: freeswitch # host.docker.internal, não "freeswitch" (PHASE 68) — ver comentário
# equivalente em fs-config acima.
ESL_HOST: host.docker.internal
ESL_PORT: "8021" ESL_PORT: "8021"
ESL_PASSWORD: ${ESL_PASSWORD} ESL_PASSWORD: ${ESL_PASSWORD}
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
@@ -188,6 +209,8 @@ services:
# — quem de fato grava e' o processo FreeSWITCH, não este worker; # — quem de fato grava e' o processo FreeSWITCH, não este worker;
# não precisa do volume montado aqui, só saber o path Docker-interno. # não precisa do volume montado aqui, só saber o path Docker-interno.
RECORDINGS_SPOOL_DIR: /recordings RECORDINGS_SPOOL_DIR: /recordings
extra_hosts:
- "host.docker.internal:host-gateway"
ai-worker: ai-worker:
build: build:

View File

@@ -11,8 +11,10 @@ Implementado em `packages/auth`, sobre o schema criado pela migration
e-mail não existe, e devolve o mesmo erro genérico (`InvalidCredentialsError`) e-mail não existe, e devolve o mesmo erro genérico (`InvalidCredentialsError`)
em qualquer caso de falha — mitiga user-enumeration por diferença de tempo em qualquer caso de falha — mitiga user-enumeration por diferença de tempo
de resposta ou mensagem. de resposta ou mensagem.
- Access token: JWT (HS256, `jose`), TTL 15 minutos, claims `{ sub, sessionId, - Access token: JWT (HS256, `jose`), TTL 12 horas (era 15 minutos até a
tenantId? }`. PHASE 69 — pedido do usuário: turno de agente de call center não pode
cair no meio, o refresh silencioso do middleware existe mas 15min forçava
ele rodar a cada poucos minutos), claims `{ sub, sessionId, tenantId? }`.
- Refresh token: string opaca aleatória (32 bytes), **nunca** JWT. Só o - Refresh token: string opaca aleatória (32 bytes), **nunca** JWT. Só o
SHA-256 fica salvo em `sessions.refresh_token_hash` — posse do token original SHA-256 fica salvo em `sessions.refresh_token_hash` — posse do token original
é a prova de identidade. é a prova de identidade.

View File

@@ -255,13 +255,48 @@ ramais) + um segundo dropdown listando as opções reais do tenant pra
cada tipo (ramais cadastrados, menus de IVR, filas, ou os valores cada tipo (ramais cadastrados, menus de IVR, filas, ou os valores
distintos de `callGroup` já usados em algum ramal). distintos de `callGroup` já usados em algum ramal).
## Dropdown de destino numa opção de IVR: ramal, fila ou outro IVR (PHASE 67)
Mesmo gap da seção acima, um nível abaixo: até aqui, uma OPÇÃO dentro de
um menu de IVR só sabia apontar pra ramal (`bridge` fixo). Rota de
entrada já suportava fila/grupo pra ENTRAR num menu, mas depois de
entrar, cada dígito só discava um ramal.
`IvrMenuOption.destinationType` (novo enum próprio,
`IvrOptionDestinationType`: `EXTENSION`/`QUEUE`/`IVR`) decide a action de
cada branch em `buildIvrDialplanExtensions` (packages/telephony), mesmo
padrão do `InboundRoute` acima:
- **EXTENSION**: inalterado — `bridge` direto pro ramal.
- **QUEUE**: `answer` + `callcenter data="<queueId>@<domain>"`.
- **IVR**: `transfer` pro `IVR_ENTRY_DESTINATION` do contexto do MENU
ALVO — reaproveita a mesma entrada que uma `InboundRoute` usa pra
entrar num IVR, então um menu vira efetivamente um sub-menu de outro.
**Sem `CALL_GROUP` aqui, de propósito** (diferente de `InboundRoute`,
que tem os 4 tipos): o dialplan de um IVR é compilado UMA VEZ, ao salvar
o menu (`compileAndActivateIvrDialplan`) — não resolvido a cada chamada
como a resolução de `InboundRoute`. Um destino `CALL_GROUP` ficaria
"quem estava no grupo no momento em que o menu foi salvo por último",
uma defasagem silenciosa que ninguém pediu — melhor não oferecer do que
oferecer errado.
**Ciclos**: "Outro IVR" cria a primeira forma de um menu apontar pra
outro (uma `InboundRoute` nunca é ela mesma um menu, então nunca podia
formar ciclo). `assertNoIvrCycle` (`ivr-menus.controller.ts`) monta o
grafo com todos os menus do tenant antes de compilar e rejeita (409)
qualquer save que criaria um ciclo (A→B→A, ou até A apontando pra si
mesmo) — checado tanto em `create` quanto `update`.
Os dois editores de IVR (form clássico e o editor visual de nós, PHASE
60/61) ganharam o mesmo par de dropdowns "Tipo"/"Destino" já usado em
Rotas de Entrada. Testado ponta a ponta: menu A com opção tipo Fila,
menu B com opção tipo Outro IVR apontando pro menu A — os dois
persistiram certo depois de um reload completo da página.
## O que falta ## O que falta
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado. - Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
- Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
destino "fila" — só ramal, dentro do contexto `default`. Rota de
entrada já suporta fila/grupo, mas um MENU de IVR ainda só bridge pra
ramal.
- Grupo de ramais só é alcançável por Rota de Entrada — não existe (e - Grupo de ramais só é alcançável por Rota de Entrada — não existe (e
não foi pedido) um jeito de discar um grupo de dentro do próprio não foi pedido) um jeito de discar um grupo de dentro do próprio
dialplan "default" via feature code. dialplan "default" via feature code.

View File

@@ -1,31 +1,47 @@
# Arquitetura de Rede # Arquitetura de Rede
## Estado atual (fase FreeSWITCH inicial) ## Estado atual (PHASE 68 — `network_mode: host`)
`freeswitch` roda na rede padrão do Docker Compose (bridge, `b2bcall_default`), `freeswitch` roda em `network_mode: host` — decisão tomada na PHASE 68 (era
igual a `postgres` e `redis`. Nenhuma porta é publicada no host — nem 8021 "pendente" antes, ver seção abaixo), pedida pelo usuário depois de dois
(ESL), nem SIP (5060/5080), nem RTP. Isso é intencional: ainda não existe ramais reais no mesmo escritório não conseguirem se ligar. `postgres`/
nenhum tronco SIP real nem ramal externo, então não há motivo pra expor nada. `redis`/`fs-config` continuam na rede bridge padrão do compose
(`b2bcall_default`); só o FreeSWITCH saiu dela.
`apps/api` roda hoje **direto no host** (fora do Docker), então usa Com host networking, o FreeSWITCH usa a stack de rede do PRÓPRIO host
`APP_DATABASE_URL`/`REDIS_URL` apontando pra `localhost` nas portas publicadas diretamente — sem NAT/bridge do Docker no meio, `local_ip_v4` resolve pro IP
pelo Postgres/Redis. Ela não consegue (nem precisa, ainda) alcançar o real da interface (`10.10.32.138` nesta VM), SIP/ESL escutam direto nas
FreeSWITCH. interfaces do host. Isso elimina uma camada de tradução de endereço que
existia antes de qualquer roteador físico entrar em cena — ver PHASE 68 no
TODO.md pro relato completo dos 3 achados (registro, áudio, e a chamada
morrendo sozinha em 32s) que motivaram e validaram essa mudança.
## Decisão pendente: `network_mode` do FreeSWITCH (agente.md secao 19) Efeitos em cascata (o FreeSWITCH sai da rede bridge do compose):
- Não resolve mais os outros containers pelo nome de serviço — `fs-config`
agora é alcançado via `http://127.0.0.1:8080/` (porta publicada só em
loopback, `xml_curl.conf.xml`).
- Os workers que chamavam ESL via nome `freeswitch`
(fs-events/fs-config/predictive-dialer) usam `host.docker.internal` (+
`extra_hosts: host-gateway`, exige Docker 20.10+).
- Segurança do Event Socket deixa de depender de "publicar só em loopback"
(não existe mais essa camada) e passa a depender inteiramente da ACL
`b2bcall_internal` já configurada (`apply-inbound-acl`) — confirmado que
continua rejeitando qualquer origem fora de loopback/rede do Docker.
Quando existir um tronco SIP real (fase Trunks), será preciso decidir entre: `apps/api` continua rodando **direto no host** (fora do Docker) — nada muda
pra ela, `ESL_HOST=127.0.0.1` já funcionava e continua funcionando (agora de
forma ainda mais direta, sem a camada de publish-port do Docker no meio).
- **`network_mode: host`**: mais simples pra SIP/RTP (sem NAT entre o ## Decisão tomada: `network_mode` do FreeSWITCH (agente.md secao 19)
container e a rede), mas perde isolamento de rede do Docker.
- **macvlan/ipvlan**: dá ao FreeSWITCH um IP próprio na rede física, sem expor
outros serviços do host: mais trabalho de configurar, melhor isolamento.
Não decidido ainda — só vira relevante quando houver um carrier/SBC real pra Historicamente listada como pendente (macvlan/ipvlan vs. host), resolvida na
conectar (secao 17: "FreeSWITCH não deverá depender de IP SIP público" — a PHASE 68 a favor de **`network_mode: host`** — não por causa de um trunk
topologia esperada é `Internet → OpenSIPS → rede SIP privada → FreeSWITCH`, PSTN real (a topologia `Internet → OpenSIPS → rede SIP privada →
então o FreeSWITCH em si tende a ficar em rede privada mesmo, o que favorece FreeSWITCH` da secao 17 continua valendo pra isso), mas por um problema
manter bridge/macvlan em vez de host). concreto de NAT entre dois ramais registrados no mesmo escritório (ver
achado abaixo). `macvlan`/`ipvlan` continuam como alternativas válidas se um
dia o isolamento de rede do host virar prioridade maior que a simplicidade
atual — não implementadas, não descartadas.
## Achado real (PHASE 65): VM atrás de roteador, ramal externo sem áudio ## Achado real (PHASE 65): VM atrás de roteador, ramal externo sem áudio

View File

@@ -78,6 +78,83 @@ RUN sed -i \
/etc/freeswitch/sip_profiles/internal.xml \ /etc/freeswitch/sip_profiles/internal.xml \
&& ! grep -q "force-register-domain\|force-subscription-domain\|force-register-db-domain" /etc/freeswitch/sip_profiles/internal.xml && ! grep -q "force-register-domain\|force-subscription-domain\|force-register-db-domain" /etc/freeswitch/sip_profiles/internal.xml
# Hairpin NAT entre dois ramais atrás do MESMO roteador (PHASE 68, achado
# real reportado pelo usuário: "1502 liga pra 1503 e não completa" —
# ambos registrados, mas a chamada falhava em 0ms com 503). Confirmado
# lendo `sofia status profile internal reg`: os dois ramais registram com
# Contact apontando pro MESMO IP público desta rede (a VM nunca tem IP
# público próprio — secao "essa máquina nunca terá IP público" — telefone
# e servidor share o mesmo NAT do escritório). `apply-nat-acl` (nat.auto,
# já testado funcionando) SÓ detecta que o cliente está atrás de NAT; sem
# `NDLB-received-in-nat-reg-contact`, o Contact salvo continua sendo o que
# o telefone auto-relatou (o IP público da rede) em vez do IP realmente
# observado no pacote UDP (a rede interna, ex.: 192.168.40.165) — originar
# uma chamada nova pro Contact salvo então tenta mandar o INVITE de volta
# pro IP público, que o roteador do escritório não sabe refletir de volta
# pra dentro (hairpin NAT clássico). Ligando este parâmetro, o Contact
# salvo passa a ser o IP/porta REALMENTE observados — a chamada entre dois
# ramais no mesmo escritório passa a rotear direto pela rede interna,
# nunca saindo (e voltando) pelo roteador.
RUN sed -i \
-e 's#<!-- *<param name="NDLB-received-in-nat-reg-contact" value="true"/> *-->#<param name="NDLB-received-in-nat-reg-contact" value="true"/>#' \
/etc/freeswitch/sip_profiles/internal.xml \
&& grep -q '<param name="NDLB-received-in-nat-reg-contact" value="true"/>' /etc/freeswitch/sip_profiles/internal.xml
# Continuação do achado acima: registro corrigido, mas a CHAMADA
# completava sem áudio. `local-network-acl="localnet.auto"` (vanilla) só
# cobre a subnet da própria interface do FreeSWITCH — os ramais reais
# ficam em OUTRAS subnets do mesmo escritório, então eram tratados como
# "de fora" e o SDP trocava o RTP/SIP anunciado pelo IP público
# compartilhado (mesmo hairpin de antes, agora na mídia). `b2bcall_lan`
# (acl.conf.xml, cobre todo RFC1918 — nunca alcançável de verdade "de
# fora" nesta VM, que nunca tem IP público próprio) resolve isso.
RUN sed -i \
-e 's#<param name="local-network-acl" value="localnet.auto"/>#<param name="local-network-acl" value="b2bcall_lan"/>#' \
/etc/freeswitch/sip_profiles/internal.xml \
&& grep -q '<param name="local-network-acl" value="b2bcall_lan"/>' /etc/freeswitch/sip_profiles/internal.xml
# Terceira camada do mesmo achado: com áudio corrigido, a chamada ainda
# morria sozinha em exatos 32s (Timer H do RFC 3261) — o 200 OK que o
# FreeSWITCH manda pro ramal que RECEBEU a chamada tinha
# `Contact: sip:...@191.240.175.55:5060` (IP público de novo), então o
# telefone nunca manda o ACK de volta (não tem como voltar por ali) e o
# FreeSWITCH fica retransmitindo o 200 OK sozinho até desistir. Achado
# via captura de pacote real (tcpdump), não suposição.
#
# `b2bcall_external_nat` (acl.conf.xml, sentido INVERTIDO de
# `b2bcall_lan`: só marca como NAT'd quem está de verdade fora de todo
# RFC1918 conhecido) escopa `apply-nat-acl` corretamente pra esta
# implantação — mantido por ser mais correto e não quebrar o fix de
# registro acima (testado: `NDLB-received-in-nat-reg-contact` não
# depende de `apply-nat-acl` bater, continua funcionando mesmo sem o
# `fs_nat=yes` marcado). MAS isto sozinho não resolveu o Contact do
# 200 OK — confirmado com uma segunda captura de pacote depois desta
# mudança, o Contact continuou com o IP público.
RUN sed -i \
-e 's#<param name="apply-nat-acl" value="nat.auto"/>#<param name="apply-nat-acl" value="b2bcall_external_nat"/>#' \
/etc/freeswitch/sip_profiles/internal.xml \
&& grep -q '<param name="apply-nat-acl" value="b2bcall_external_nat"/>' /etc/freeswitch/sip_profiles/internal.xml
# O fix de verdade pro Contact: `ext-rtp-ip`/`ext-sip-ip` (STUN,
# `$${external_sip_ip}` = 191.240.175.55) fazem FreeSWITCH sempre ter
# um endereço "público" pronto pra usar como identidade própria em
# respostas SIP — independente de qualquer ACL de NAT, é só o valor
# que o profile usa quando "se apresentando" externamente. Esta VM
# NUNCA tem IP público próprio de verdade (confirmado pelo usuário) —
# pro profile "internal" (só ramais registrados deste escritório,
# nunca um trunk/SBC de verdade), esse endereço "externo" nunca é
# correto, só existe pra causar exatamente este bug. Removendo os 2
# params, o profile cai automaticamente pra `rtp-ip`/`sip-ip`
# (10.10.32.138) pra tudo, sem exceção — não tem mais endereço "errado"
# pro FreeSWITCH escolher. O profile "external" (reservado pra um
# trunk PSTN real, nunca usado ainda nesta implantação) mantém
# `ext-rtp-ip`/`ext-sip-ip` intactos — só o "internal" precisava disso.
RUN sed -i \
-e '/<param name="ext-rtp-ip" value="\$\${external_rtp_ip}"\/>/d' \
-e '/<param name="ext-sip-ip" value="\$\${external_sip_ip}"\/>/d' \
/etc/freeswitch/sip_profiles/internal.xml \
&& ! grep -q 'ext-rtp-ip\|ext-sip-ip' /etc/freeswitch/sip_profiles/internal.xml
# RTP num range fixo e pequeno (PHASE 54, docs/EXTENSIONS.md) — o range # RTP num range fixo e pequeno (PHASE 54, docs/EXTENSIONS.md) — o range
# vanilla default (16384-32768, ~16k portas) é inviável de publicar uma a # vanilla default (16384-32768, ~16k portas) é inviável de publicar uma a
# uma no host; achado ao tentar registrar um softphone de fora da rede # uma no host; achado ao tentar registrar um softphone de fora da rede

View File

@@ -10,5 +10,58 @@
<node type="allow" cidr="::1/128"/> <node type="allow" cidr="::1/128"/>
<node type="allow" cidr="172.16.0.0/12"/> <node type="allow" cidr="172.16.0.0/12"/>
</list> </list>
<!-- PHASE 68 — achado real: depois de corrigir o registro (NDLB-
received-in-nat-reg-contact, Dockerfile), a chamada completava
mas sem áudio. Causa: `local-network-acl="localnet.auto"`
(padrão vanilla) só cobre a subnet da PRÓPRIA interface do
FreeSWITCH (10.10.32.0/24, com network_mode: host) — os ramais
de verdade estão em OUTRAS subnets do mesmo escritório
(192.168.40.0/24, 192.168.44.0/24), então o FreeSWITCH os
tratava como "fora da rede local" e trocava o RTP/SIP anunciado
no SDP pelo IP público compartilhado (Ext-RTP-IP/Ext-SIP-IP,
191.240.175.55) — o mesmo hairpin NAT de antes, só que na
mídia em vez do registro. Esta VM nunca tem IP público próprio
(é sempre RFC1918 atrás do NAT do escritório), então qualquer
cliente que realmente CONSEGUE alcançar este servidor já é,
por definição, parte dessa rede privada — cobrir todo o RFC1918
aqui é seguro pra esta implantação (nunca é "de fora" de
verdade) e evita ter que listar cada subnet/VLAN do escritório
uma por uma. -->
<list name="b2bcall_lan" default="deny">
<node type="allow" cidr="10.0.0.0/8"/>
<node type="allow" cidr="172.16.0.0/12"/>
<node type="allow" cidr="192.168.0.0/16"/>
</list>
<!-- PHASE 68 (continuação) — achado real: com áudio já corrigido, o
200 OK que o FreeSWITCH manda pra um ramal como 1503 ainda tinha
`Contact: sip:1502@191.240.175.55:5060` (o IP público de novo) —
o telefone nunca manda o ACK de volta (não é dele, não sabe
voltar), o FreeSWITCH fica retransmitindo o 200 OK sozinho
(0.5s/1s/2s/4s/4s...) até desistir em exatos 32s (Timer H do
RFC 3261) e derrubar a chamada. Causa: o Contact que o
FreeSWITCH usa pra SE IDENTIFICAR numa resposta SIP é decidido
por `apply-nat-acl` (nat.auto), não por `local-network-acl` —
são duas checagens INDEPENDENTES (uma pro SDP/mídia, outra pro
cabeçalho SIP). `nat.auto` (RFC1918 exceto a própria subnet do
FreeSWITCH) continua marcando ramais de OUTRAS subnets do
escritório como "precisa de IP externo" nesta camada, mesmo já
não afetando mais o SDP. Não dá pra simplesmente trocar
`apply-nat-acl` pra `b2bcall_lan` (mesmo sentido de match de
`local-network-acl`) — isso pararia de marcar esses ramais como
NAT'd, e SEM essa marcação o `NDLB-received-in-nat-reg-contact`
(Dockerfile, o fix do REGISTRO) para de disparar, voltando o
bug original. Por isso esta é uma ACL NOVA e SEPARADA, com o
sentido INVERTIDO de `b2bcall_lan`: "NAT'd de verdade" só quem
está FORA de todo RFC1918 conhecido desta implantação — nunca
alcançável de verdade nesta VM (nunca tem IP público próprio),
mas mantém o comportamento correto se um dia existir uma
conexão genuinamente externa (trunk real via IP público). -->
<list name="b2bcall_external_nat" default="allow">
<node type="deny" cidr="10.0.0.0/8"/>
<node type="deny" cidr="172.16.0.0/12"/>
<node type="deny" cidr="192.168.0.0/16"/>
</list>
</network-lists> </network-lists>
</configuration> </configuration>

View File

@@ -6,7 +6,13 @@
vanilla continua valendo como fallback quando "not found". vanilla continua valendo como fallback quando "not found".
Credenciais substituidas em runtime pelo entrypoint.sh — nunca Credenciais substituidas em runtime pelo entrypoint.sh — nunca
ficam de verdade na imagem (mesmo padrao do ESL_PASSWORD). --> ficam de verdade na imagem (mesmo padrao do ESL_PASSWORD). -->
<param name="gateway-url" value="http://fs-config:8080/" bindings="directory|dialplan"/> <!-- 127.0.0.1, nao "fs-config" (PHASE 68): com o FreeSWITCH em
network_mode: host, ele nao esta mais na rede bridge do
compose, entao o DNS embutido do Docker (nomes de servico) nao
resolve mais pra ele. fs-config publica 8080 so em loopback do
host (docker-compose.yml) — o FreeSWITCH, agora usando a
propria stack de rede do host, alcanca por ali. -->
<param name="gateway-url" value="http://127.0.0.1:8080/" bindings="directory|dialplan"/>
<param name="gateway-credentials" value="__FS_CONFIG_USER__:__FS_CONFIG_PASSWORD__"/> <param name="gateway-credentials" value="__FS_CONFIG_USER__:__FS_CONFIG_PASSWORD__"/>
<param name="auth-scheme" value="basic"/> <param name="auth-scheme" value="basic"/>
<param name="timeout" value="5"/> <param name="timeout" value="5"/>

View File

@@ -1,7 +1,10 @@
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import { SignJWT, jwtVerify, type JWTPayload } from "jose"; import { SignJWT, jwtVerify, type JWTPayload } from "jose";
const ACCESS_TOKEN_TTL = "15m"; // Call center: agente logado o turno inteiro sem cair no meio de uma
// ligação — 15min (default anterior) forçava refresh silencioso a cada
// poucos minutos, arriscando um gap bem no meio de uma chamada.
const ACCESS_TOKEN_TTL = "12h";
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 dias export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 dias
export interface AccessTokenClaims extends JWTPayload { export interface AccessTokenClaims extends JWTPayload {

View File

@@ -0,0 +1,5 @@
-- PHASE 67: dropdown de destino (ramal/fila/outro IVR) numa opção de IVR,
-- mesmo gap que existia em Rotas de Entrada até a PHASE 62.
CREATE TYPE "ivr_option_destination_type" AS ENUM ('EXTENSION', 'QUEUE', 'IVR');
ALTER TABLE "ivr_menu_options" ADD COLUMN "destination_type" "ivr_option_destination_type" NOT NULL DEFAULT 'EXTENSION';

View File

@@ -545,6 +545,14 @@ model IvrMenu {
@@map("ivr_menus") @@map("ivr_menus")
} }
enum IvrOptionDestinationType {
EXTENSION
QUEUE
IVR
@@map("ivr_option_destination_type")
}
model IvrMenuOption { model IvrMenuOption {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid tenantId String @map("tenant_id") @db.Uuid
@@ -552,8 +560,27 @@ model IvrMenuOption {
digit String // "0".."9", "*" ou "#" — validado na API, é o que vira o regex da extension de branching digit String // "0".."9", "*" ou "#" — validado na API, é o que vira o regex da extension de branching
destinationNumber String @map("destination_number") // ramal real dentro do contexto abaixo // PHASE 67 — achado real reportado pelo usuário: o destino de uma opção
destinationContext String @default("default") @map("destination_context") // de IVR só podia ser ramal, sem dropdown de fila/outro menu, igual o
// gap que existia em Rotas de Entrada até a PHASE 62. Mesmo `destination
// Type` decide como `buildIvrDialplanExtensions` (packages/telephony)
// interpreta os 2 campos abaixo:
// EXTENSION -> destinationNumber é o número do ramal (bridge, igual
// sempre foi); destinationContext não usado
// QUEUE -> destinationNumber é o Queue.id (answer + callcenter,
// mesmo padrão de InboundRoute); destinationContext não usado
// IVR -> destinationNumber vira sempre IVR_ENTRY_DESTINATION;
// destinationContext é o IvrMenu.context do menu alvo
// (transfer pro próprio contexto, igual InboundRoute
// encaminha pra um IVR). Sem CALL_GROUP aqui de propósito:
// diferente de InboundRoute, o dialplan de um IVR é
// compilado uma vez ao salvar (não resolvido por chamada),
// então "quem está no grupo agora" ficaria desatualizado
// até a próxima edição — não pedido pelo usuário, não
// implementado.
destinationType IvrOptionDestinationType @default(EXTENSION) @map("destination_type")
destinationNumber String @map("destination_number") // ramal real dentro do contexto abaixo
destinationContext String @default("default") @map("destination_context")
label String? label String?

View File

@@ -30,6 +30,11 @@ export interface DirectoryUserParams {
* contra a documentação do FreeSWITCH antes de close (a primeira versão * contra a documentação do FreeSWITCH antes de close (a primeira versão
* usava `call-group`, que não é lido por nada). */ * usava `call-group`, que não é lido por nada). */
callGroup?: string | null; callGroup?: string | null;
/** Lista de codecs de áudio permitidos pro ramal, separados por vírgula
* (ex.: "PCMU,PCMA,OPUS") — vira `absolute_codec_string`, a variável
* que o FreeSWITCH usa pra restringir/ordenar os codecs oferecidos na
* negociação SDP pra este usuário especificamente. */
codecs?: string;
} }
/** /**
@@ -67,6 +72,7 @@ export function buildDirectoryUserXml(params: DirectoryUserParams): string {
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/> <variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/> <variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""} ${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""}
${params.codecs ? `<variable name="absolute_codec_string" value="${xmlEscape(params.codecs)}"/>` : ""}
</variables> </variables>
</user> </user>
</users> </users>

View File

@@ -23,6 +23,7 @@ function escapeRegexLiteral(digit: string): string {
export interface IvrMenuOptionInput { export interface IvrMenuOptionInput {
digit: string; digit: string;
destinationType: "EXTENSION" | "QUEUE" | "IVR";
destinationNumber: string; destinationNumber: string;
destinationContext: string; destinationContext: string;
} }
@@ -38,6 +39,14 @@ export interface IvrMenuOptionInput {
* por uma extension nunca é enxergada por OUTRA extension na mesma * por uma extension nunca é enxergada por OUTRA extension na mesma
* passada; só um `transfer` (nova consulta de dialplan) resolve isso — * passada; só um `transfer` (nova consulta de dialplan) resolve isso —
* e uma extension por dígito, casando por `destination_number` normal. * e uma extension por dígito, casando por `destination_number` normal.
*
* PHASE 67 (dropdown de destino real: ramal/fila/outro IVR) — cada branch
* decide a action pelo `destinationType`, mesmo padrão de
* `buildInboundRouteXml`: EXTENSION continua `bridge` direto pro ramal;
* QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro
* `IVR_ENTRY_DESTINATION` do contexto alvo (reaproveita a MESMA entrada
* que qualquer chamada de fora usa pra entrar naquele menu). Sem
* CALL_GROUP aqui — motivo no comentário do schema (`IvrMenuOption`).
*/ */
export function buildIvrDialplanExtensions( export function buildIvrDialplanExtensions(
menu: { context: string; greeting?: string | null }, menu: { context: string; greeting?: string | null },
@@ -61,14 +70,27 @@ export function buildIvrDialplanExtensions(
], ],
}; };
const branches: DialplanExtensionInput[] = options.map((opt, i) => ({ const branches: DialplanExtensionInput[] = options.map((opt, i) => {
name: `IVR opção ${opt.digit}`, let actions: DialplanExtensionInput["actions"];
conditionField: "destination_number", if (opt.destinationType === "QUEUE") {
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`, actions = [
continueOnFalse: false, { application: "answer" },
order: 10 + i, { application: "callcenter", data: `${opt.destinationNumber}@\${domain_name}` },
actions: [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }], ];
})); } else if (opt.destinationType === "IVR") {
actions = [{ application: "transfer", data: `${IVR_ENTRY_DESTINATION} XML ${opt.destinationContext}` }];
} else {
actions = [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }];
}
return {
name: `IVR opção ${opt.digit}`,
conditionField: "destination_number",
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`,
continueOnFalse: false,
order: 10 + i,
actions,
};
});
return [entry, ...branches]; return [entry, ...branches];
} }