Compare commits
9 Commits
97ef8a6ba8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c23513f42 | |||
| dd095077dd | |||
| 3fe571bfb0 | |||
| e75872faec | |||
| 9d3c84064f | |||
| b33d35db50 | |||
| 5c23b105b3 | |||
| 2134fa0aa4 | |||
| 432cd55adb |
327
TODO.md
327
TODO.md
@@ -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
|
||||
(não vazia) assim que o agente com ramal vinculado abre `/app`.
|
||||
|
||||
## PHASE 67 — Dropdown de destino (fila/outro IVR) numa opção de IVR
|
||||
(pedido do usuário: "dentro do IVR tem que dar a opção de eu colocar como
|
||||
destino não apenas os ramais mas tb as filas e outra IVR, como feito na
|
||||
rota de entrada")
|
||||
- [x] Mesmo gap que existia em Rotas de Entrada até a PHASE 62:
|
||||
`IvrMenuOption` só tinha `destinationNumber` (sempre interpretado
|
||||
como ramal), sem tipo nenhum. Novo enum próprio
|
||||
`IvrOptionDestinationType` (EXTENSION/QUEUE/IVR) — SEM
|
||||
`CALL_GROUP` de propósito: diferente de `InboundRoute` (resolvido
|
||||
por chamada, sempre fresco), o dialplan de um IVR é compilado UMA
|
||||
VEZ ao salvar o menu — "quem está no grupo agora" ficaria
|
||||
desatualizado até a próxima edição, comportamento silenciosamente
|
||||
stale que ninguém pediu.
|
||||
- [x] `buildIvrDialplanExtensions` (packages/telephony) — cada branch de
|
||||
dígito decide a action pelo `destinationType`, mesmo padrão de
|
||||
`buildInboundRouteXml`: EXTENSION continua `bridge` direto;
|
||||
QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro
|
||||
`IVR_ENTRY_DESTINATION` do contexto do menu alvo — reaproveita a
|
||||
MESMA entrada que uma `InboundRoute` usa pra entrar naquele menu.
|
||||
- [x] "Outro IVR" cria um grafo entre menus que nunca existia antes (uma
|
||||
`InboundRoute` não é ela mesma um menu, nunca podia formar ciclo).
|
||||
`assertNoIvrCycle` (novo, `ivr-menus.controller.ts`) monta o grafo
|
||||
com TODOS os menus do tenant antes de compilar e rejeita
|
||||
(409) qualquer combinação que criaria um ciclo — A→B→A ou até
|
||||
A→A — checado tanto em `create` quanto `update`.
|
||||
- [x] Frontend: os dois editores de IVR (form clássico `ivr-view.tsx` e
|
||||
o editor visual `ivr-flow-editor.tsx`, PHASE 60/61) ganharam o
|
||||
mesmo dropdown "Tipo" + "Destino" condicional já usado em Rotas de
|
||||
Entrada — precisou buscar `queues` na `page.tsx` do IVR (só
|
||||
`extensions` era buscado antes) e passar a lista de OUTROS menus
|
||||
(excluindo o próprio, de propósito, pra reduzir a chance de
|
||||
self-reference direto na UI — o backend segue sendo quem
|
||||
garante isso de verdade).
|
||||
- [x] Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR
|
||||
reais criados na hora: Menu A com opção tipo Fila (aponta pra
|
||||
"Fila QA"), Menu B com opção tipo Outro IVR (aponta pro Menu A) —
|
||||
os dois persistiram com o tipo certo, sobrevivendo a um reload
|
||||
completo da página.
|
||||
|
||||
## PHASE 68 — Ramal registrado mas chamada não completava / sem áudio
|
||||
(reportado pelo usuário testando com 2 telefones IP reais, 1502 e 1503)
|
||||
- [x] **Achado 1 (chamada não completava, 503 instantâneo)**: os dois
|
||||
ramais registravam com o Contact apontando pro MESMO IP público
|
||||
compartilhado desta rede (esta VM nunca tem IP público próprio —
|
||||
é sempre RFC1918 atrás do NAT do escritório). Ao originar uma
|
||||
chamada, o FreeSWITCH tentava mandar o INVITE de volta pra esse
|
||||
IP público — hairpin NAT clássico, o roteador do escritório não
|
||||
sabe refletir esse tráfego de volta pra dentro. Corrigido com
|
||||
`NDLB-received-in-nat-reg-contact` (Dockerfile) — o Contact salvo
|
||||
passa a ser o IP/porta REALMENTE observados no pacote (a rede
|
||||
interna), não o que o telefone auto-relata.
|
||||
- [x] `network_mode: host` no serviço `freeswitch` (pedido do usuário,
|
||||
"sempre é problema NAT do Docker") — tira a camada de NAT/bridge
|
||||
do Docker do meio. Quebra em cascata corrigida: FreeSWITCH sai da
|
||||
rede bridge do compose, então (a) não resolve mais os OUTROS
|
||||
containers pelo nome de serviço — `fs-config` virou
|
||||
`http://127.0.0.1:8080/` (porta publicada só em loopback,
|
||||
xml_curl.conf.xml) —, e (b) os workers que chamavam ESL via nome
|
||||
`freeswitch` (fs-events/fs-config/predictive-dialer) passam a usar
|
||||
`host.docker.internal` (+ `extra_hosts: host-gateway`, Docker
|
||||
20.10+). Segurança do Event Socket deixa de depender de publicar
|
||||
só em loopback (não existe mais essa camada) e passa a depender
|
||||
inteiramente da ACL `b2bcall_internal` já configurada
|
||||
(`apply-inbound-acl`) — confirmado que continua rejeitando
|
||||
qualquer origem fora de loopback/rede do Docker.
|
||||
- [x] **Achado 2 (chamada completava, mas sem áudio)**: com o registro
|
||||
corrigido, a ligação passou a completar, mas sem RTP (confirmado
|
||||
pelo usuário com `sngrep`). `local-network-acl="localnet.auto"`
|
||||
(padrão vanilla) só cobre a subnet da PRÓPRIA interface do
|
||||
FreeSWITCH — os ramais reais ficam em OUTRAS subnets do mesmo
|
||||
escritório, então eram tratados como "de fora" e o SDP trocava o
|
||||
RTP/SIP anunciado pelo IP público compartilhado de novo (mesmo
|
||||
hairpin, agora na mídia). Corrigido com uma ACL própria
|
||||
(`b2bcall_lan`, cobre todo RFC1918 — nunca alcançável de verdade
|
||||
"de fora" nesta VM) referenciada em `local-network-acl`.
|
||||
- [x] **Achado 3 (chamada completava e tinha áudio, mas morria sozinha
|
||||
em exatos 32s)**: mesmo com áudio já corrigido, toda chamada
|
||||
morria sozinha depois de exatos 32 segundos — o Timer H do
|
||||
RFC 3261 (64×T1, prazo padrão de qualquer UAS esperando o ACK de
|
||||
um 2xx). Só descoberto de verdade com captura de pacote real
|
||||
(`tcpdump`, instalado nesta sessão — não estava no host): o
|
||||
`200 OK` que o FreeSWITCH manda pro ramal que RECEBEU a chamada
|
||||
ainda tinha `Contact: sip:...@191.240.175.55:5060` (o IP público
|
||||
de novo, MESMO com o SDP já mostrando o IP certo) — o telefone
|
||||
nunca manda o ACK de volta (não tem como chegar por ali), o
|
||||
FreeSWITCH retransmite o 200 OK sozinho (0.5s/1s/2s/4s/4s...) até
|
||||
desistir e derrubar a chamada. `apply-nat-acl`/`local-network-acl`
|
||||
(achados 1/2) não bastam aqui — são checagens independentes, e
|
||||
trocar `apply-nat-acl` pra escopo `b2bcall_lan` (testado) não
|
||||
mudou nada nesse Contact. O fix de verdade: `ext-rtp-ip`/
|
||||
`ext-sip-ip` (STUN, resolvem pro IP público) REMOVIDOS do profile
|
||||
`internal` (Dockerfile) — sem endereço "externo" nenhum
|
||||
configurado, o FreeSWITCH não tem mais como escolher errado,
|
||||
cai sempre em `rtp-ip`/`sip-ip` (10.10.32.138). Único profile
|
||||
afetado; `external` (reservado pra um trunk PSTN real, nunca
|
||||
usado ainda) mantém os dois params intactos.
|
||||
- [x] Diagnosticado ao vivo lendo `/var/log/freeswitch/freeswitch.log`
|
||||
em tempo real durante as tentativas de chamada do usuário (nunca
|
||||
supondo causa sem ver o log) e, pro achado 3, uma captura de
|
||||
pacote real via `tcpdump` — cada achado confirmado por evidência
|
||||
exata (`NORMAL_TEMPORARY_FAILURE`/503 instantâneo pro achado 1;
|
||||
endereço do `c=`/RTP-IP pro achado 2; cabeçalho `Contact:` do
|
||||
`200 OK` capturado byte a byte pro achado 3). Confirmado resolvido
|
||||
pelo usuário com chamadas reais nos dois sentidos (1502→1503 e
|
||||
1503→1502), áudio bidirecional e chamada sobrevivendo bem além
|
||||
dos 32s que travavam toda tentativa antes do achado 3.
|
||||
|
||||
## PHASE 69 — Sessão web expirando cedo demais pra call center (pedido do
|
||||
usuário: "no minimo 12 hs")
|
||||
- [x] Access token JWT (`packages/auth/src/tokens.ts`) e o teto absoluto do
|
||||
cookie de sessão (duplicado em `login`/`post-login`/`select-tenant`
|
||||
route handlers + `middleware.ts`) subiram de 15min/8h pra 12h. O
|
||||
refresh silencioso do middleware (decodifica o `exp` do JWT, renova
|
||||
via `/auth/refresh` uns 60s antes de vencer) continua existindo e
|
||||
deslizando a sessão a cada requisição — 12h passa a ser só o teto de
|
||||
segurança pra quando esse refresh falhar (aba parada, logout em
|
||||
outro lugar), não o intervalo real de renovação.
|
||||
- [x] Verificado com login real: token decodificado mostra exp−iat = 12h
|
||||
exatas.
|
||||
|
||||
## PHASE 70 — Softphone WebRTC não registrava, ficava cinza pra sempre
|
||||
(usuário configurou o proxy QA de verdade e testou o ramal 1501 do tenant
|
||||
teste01 — "so fica cinza")
|
||||
- [x] Confirmado primeiro que a config em si estava certa
|
||||
(`GET /platform/webrtc-proxy` devolvia a URL nova) e que a
|
||||
infraestrutura de rede/proxy funcionava — DNS resolve, TCP conecta,
|
||||
certificado TLS válido, e um handshake WebSocket manual (`ws`, com
|
||||
header `Origin`) completa normal. Um REGISTER SIP cru (autenticação
|
||||
digest manual) através do proxy chegou até o FreeSWITCH de verdade
|
||||
(`User-Agent: FreeSWITCH-mod_sofia` na resposta) e voltou 200 OK —
|
||||
credenciais do ramal 1501 corretas, proxy e FreeSWITCH 100% ok.
|
||||
- [x] **Achado real, bug no próprio código-fonte do widget** (não
|
||||
descoberto antes porque a verificação da PHASE 66 só conferia se o
|
||||
`<script data-sip-*>` recebia os atributos certos, nunca deixava o
|
||||
widget tentar registrar de verdade contra um proxy ao vivo):
|
||||
`widgetStorage.mergeConfig` (`src/widget/widgetStorage.js`) só
|
||||
calculava o campo `aor` (`sip:user@domain`) no branch de config
|
||||
SALVA MANUALMENTE (localStorage) — no branch usado quando a config
|
||||
vem de `data-sip-*`/`window.HandphoneConfig` (nosso caso, sempre),
|
||||
`aor` nunca era calculado. `connect()` (`useSip.js`) quebra em
|
||||
`cfg.aor.startsWith(...)` com `aor` undefined — erro só visível no
|
||||
console do navegador (`[Widget] Connect failed: TypeError...`),
|
||||
nunca pro usuário, que só via o botão preso em cinza (desconectado)
|
||||
pra sempre, sem nenhuma tentativa de conexão visível.
|
||||
- [x] Fix: `mergeConfig` agora calcula `aor` a partir de
|
||||
`externalConfig.username`/`externalConfig.domain` também no branch
|
||||
sem config salva. Patch na mesma cópia local do
|
||||
`git.falehandix.com.br/Handix/handphone-2.0` já usada na PHASE 66
|
||||
(nunca enviado pro repo externo do usuário) — rebuildado e
|
||||
revendorizado em `apps/frontend/public/handphone.js`.
|
||||
- [x] Testado ponta a ponta com Playwright + navegador real, fora do app
|
||||
(uma página HTML isolada com os `data-sip-*` do ramal 1501 de
|
||||
verdade, sem depender do login do tenant): REGISTER completa com
|
||||
200 OK observado no tráfego WebSocket real, botão do widget muda de
|
||||
cinza pra verde (conectado).
|
||||
|
||||
## PHASE 71 — Botão de copiar senha SIP não fazia nada (pedido do
|
||||
usuário: "o botão de copiar a senha dentro do cadastro do ramal está com
|
||||
problema")
|
||||
- [x] **Achado real**: `navigator.clipboard` só existe em "contexto
|
||||
seguro" (HTTPS ou `localhost`) — este ambiente é HTTP puro,
|
||||
acessado pelo IP da VM (nunca localhost pro usuário de verdade).
|
||||
`SecretReveal` (usado tanto na criação de ramal quanto em "ver
|
||||
senha atual") chamava `navigator.clipboard.writeText` direto, sem
|
||||
try/catch — em contexto inseguro isso derruba/rejeita sem erro
|
||||
visível nenhum, o botão simplesmente não fazia nada. Confirmado
|
||||
com Playwright acessando `http://10.10.32.138:3001` (IP real, não
|
||||
localhost): `navigator.clipboard.writeText` de fato `undefined`
|
||||
nesse contexto — reproduz o sintoma exato.
|
||||
- [x] Fix: fallback pra `document.execCommand("copy")` (textarea
|
||||
temporário fora da tela) quando a Clipboard API moderna não existe
|
||||
ou falha — funciona em qualquer contexto, inclusive HTTP. Se as
|
||||
duas formas falharem, mostra aviso pra selecionar e copiar
|
||||
manualmente (Ctrl+C) em vez de falhar em silêncio — o campo já é
|
||||
`select-all`.
|
||||
- [x] Testado ponta a ponta com Playwright acessando o IP real da VM
|
||||
(não localhost, pra genuinamente cair no contexto inseguro): os
|
||||
dois fluxos (senha mostrada na criação do ramal, e "ver senha
|
||||
atual" de um ramal já existente) mostram o ícone verde de sucesso
|
||||
via o fallback.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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 {
|
||||
@IsString()
|
||||
@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 {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
@@ -55,6 +68,16 @@ export class UpdateExtensionDto {
|
||||
@Matches(/^[0-9]{2,20}$/)
|
||||
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()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
|
||||
@@ -209,8 +209,11 @@ export class ExtensionsController {
|
||||
tx.extension.updateMany({
|
||||
where: { id, tenantId, deletedAt: null },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}),
|
||||
...(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.callGroup !== undefined ? { callGroup: dto.callGroup } : {}),
|
||||
},
|
||||
|
||||
@@ -3,10 +3,18 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsStri
|
||||
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
|
||||
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
|
||||
|
||||
const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const;
|
||||
|
||||
export class IvrMenuOptionDto {
|
||||
@IsIn(ALLOWED_IVR_DIGITS)
|
||||
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()
|
||||
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
|
||||
destinationNumber!: string;
|
||||
|
||||
@@ -49,6 +49,53 @@ function promptHostPath(tenantId: string, menuId: string): string {
|
||||
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
|
||||
* editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
|
||||
@@ -133,22 +180,31 @@ export class IvrMenusController {
|
||||
const prisma = getPrismaClient();
|
||||
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;
|
||||
try {
|
||||
menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||
await assertNoIvrCycle(tx, tenantId, dto.context, createOptions);
|
||||
const created = await tx.ivrMenu.create({
|
||||
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
|
||||
});
|
||||
await tx.ivrMenuOption.createMany({
|
||||
data: dto.options.map((opt) => ({
|
||||
data: createOptions.map((opt, i) => ({
|
||||
tenantId,
|
||||
ivrMenuId: created.id,
|
||||
digit: opt.digit,
|
||||
destinationType: opt.destinationType,
|
||||
destinationNumber: opt.destinationNumber,
|
||||
destinationContext: opt.destinationContext ?? "default",
|
||||
label: opt.label,
|
||||
positionX: opt.positionX,
|
||||
positionY: opt.positionY,
|
||||
destinationContext: opt.destinationContext,
|
||||
label: dto.options[i].label,
|
||||
positionX: dto.options[i].positionX,
|
||||
positionY: dto.options[i].positionY,
|
||||
})),
|
||||
});
|
||||
return created;
|
||||
@@ -160,11 +216,6 @@ export class IvrMenusController {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({
|
||||
digit: o.digit,
|
||||
destinationNumber: o.destinationNumber,
|
||||
destinationContext: o.destinationContext ?? "default",
|
||||
}));
|
||||
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, createOptions);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
@@ -219,7 +270,24 @@ export class IvrMenusController {
|
||||
);
|
||||
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) => {
|
||||
if (dto.options) {
|
||||
await assertNoIvrCycle(tx, tenantId, existing.context, options);
|
||||
}
|
||||
const updated = await tx.ivrMenu.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -232,12 +300,13 @@ export class IvrMenusController {
|
||||
if (dto.options) {
|
||||
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
|
||||
await tx.ivrMenuOption.createMany({
|
||||
data: dto.options.map((opt) => ({
|
||||
data: dto.options.map((opt, i) => ({
|
||||
tenantId,
|
||||
ivrMenuId: id,
|
||||
digit: opt.digit,
|
||||
destinationType: options[i].destinationType,
|
||||
destinationNumber: opt.destinationNumber,
|
||||
destinationContext: opt.destinationContext ?? "default",
|
||||
destinationContext: options[i].destinationContext,
|
||||
label: opt.label,
|
||||
positionX: opt.positionX,
|
||||
positionY: opt.positionY,
|
||||
@@ -247,10 +316,6 @@ export class IvrMenusController {
|
||||
return updated;
|
||||
});
|
||||
|
||||
const options: IvrMenuOptionInput[] = dto.options
|
||||
? dto.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext ?? "default" }))
|
||||
: existing.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext }));
|
||||
|
||||
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, options);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
@@ -342,6 +407,7 @@ export class IvrMenusController {
|
||||
|
||||
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
||||
digit: o.digit,
|
||||
destinationType: o.destinationType,
|
||||
destinationNumber: o.destinationNumber,
|
||||
destinationContext: o.destinationContext,
|
||||
}));
|
||||
@@ -380,6 +446,7 @@ export class IvrMenusController {
|
||||
|
||||
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
||||
digit: o.digit,
|
||||
destinationType: o.destinationType,
|
||||
destinationNumber: o.destinationNumber,
|
||||
destinationContext: o.destinationContext,
|
||||
}));
|
||||
|
||||
@@ -78,6 +78,7 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
|
||||
tenantId: extension.tenantId,
|
||||
extensionId: extension.id,
|
||||
callGroup: extension.callGroup,
|
||||
codecs: extension.codecs,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -29,7 +29,12 @@ export async function POST(request: Request) {
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function POST() {
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8,
|
||||
maxAge: 60 * 60 * 12,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/lib/session";
|
||||
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 {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -21,7 +21,9 @@ function extractErrorMessage(err: unknown): string {
|
||||
|
||||
export interface IvrMenuOptionInput {
|
||||
digit: string;
|
||||
destinationType?: IvrOptionDestinationType;
|
||||
destinationNumber: string;
|
||||
destinationContext?: string;
|
||||
label?: string;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
|
||||
@@ -19,14 +19,23 @@ import "@xyflow/react/dist/style.css";
|
||||
import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions";
|
||||
|
||||
interface OptionState {
|
||||
key: string;
|
||||
digit: string;
|
||||
destinationNumber: string;
|
||||
destinationType: IvrOptionDestinationType;
|
||||
destinationValue: string;
|
||||
label: string;
|
||||
positionX: number | null;
|
||||
positionY: number | null;
|
||||
@@ -37,6 +46,8 @@ interface EntryNodeData extends Record<string, unknown> {}
|
||||
interface OptionNodeData extends Record<string, unknown> {
|
||||
option: OptionState;
|
||||
extensions: Extension[];
|
||||
queues: Queue[];
|
||||
menus: IvrMenu[];
|
||||
usedDigits: string[];
|
||||
onChange: (key: string, patch: Partial<OptionState>) => void;
|
||||
onRemove: (key: string) => void;
|
||||
@@ -56,7 +67,7 @@ function EntryNode(_props: NodeProps<Node<EntryNodeData, "entry">>) {
|
||||
}
|
||||
|
||||
function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
|
||||
const { option, extensions, usedDigits, onChange, onRemove } = data;
|
||||
const { option, extensions, queues, menus, usedDigits, onChange, onRemove } = data;
|
||||
return (
|
||||
<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} />
|
||||
@@ -85,16 +96,40 @@ function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
|
||||
</div>
|
||||
<Select
|
||||
className="nodrag"
|
||||
value={option.destinationNumber}
|
||||
onChange={(e) => onChange(option.key, { destinationNumber: e.target.value })}
|
||||
value={option.destinationType}
|
||||
onChange={(e) => onChange(option.key, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
|
||||
>
|
||||
<option value="">Selecione um ramal…</option>
|
||||
{extensions.map((ext) => (
|
||||
<option key={ext.id} value={ext.number}>
|
||||
{ext.number} — {ext.name}
|
||||
{IVR_OPTION_DESTINATION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{IVR_OPTION_DESTINATION_TYPE_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</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
|
||||
className="nodrag"
|
||||
value={option.label}
|
||||
@@ -114,6 +149,8 @@ function layoutNodes(
|
||||
options: OptionState[],
|
||||
entryPosition: { x: number; y: number } | null,
|
||||
extensions: Extension[],
|
||||
queues: Queue[],
|
||||
menus: IvrMenu[],
|
||||
onChange: OptionNodeData["onChange"],
|
||||
onRemove: OptionNodeData["onRemove"],
|
||||
) {
|
||||
@@ -129,7 +166,7 @@ function layoutNodes(
|
||||
id: option.key,
|
||||
type: "option",
|
||||
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,
|
||||
}));
|
||||
const edges: Edge[] = options.map((option) => ({
|
||||
@@ -155,18 +192,29 @@ function nextKey(): string {
|
||||
* direto os campos (dígito/ramal/descrição); salvar recompila o
|
||||
* 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[]>(
|
||||
() =>
|
||||
menu.options.map((o) => ({
|
||||
key: o.id,
|
||||
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 ?? "",
|
||||
positionX: o.positionX,
|
||||
positionY: o.positionY,
|
||||
})),
|
||||
[menu.options],
|
||||
[menu.options, menus],
|
||||
);
|
||||
const initialEntryPosition = useMemo(
|
||||
() => (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(
|
||||
() => 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)
|
||||
[options, extensions, onChange, onRemove],
|
||||
[options, extensions, queues, menus, onChange, onRemove],
|
||||
);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
@@ -212,7 +260,10 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
||||
setSaved(false);
|
||||
const usedDigits = new Set(options.map((o) => o.digit));
|
||||
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() {
|
||||
@@ -221,8 +272,8 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
||||
setError("O menu precisa de pelo menos uma opção.");
|
||||
return;
|
||||
}
|
||||
if (options.some((o) => !o.destinationNumber.trim())) {
|
||||
setError("Toda opção precisa de um ramal de destino selecionado.");
|
||||
if (options.some((o) => !o.destinationValue.trim())) {
|
||||
setError("Toda opção precisa de um destino selecionado.");
|
||||
return;
|
||||
}
|
||||
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.
|
||||
const payload: IvrMenuOptionInput[] = options.map((o) => {
|
||||
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 {
|
||||
digit: o.digit,
|
||||
destinationNumber: o.destinationNumber.trim(),
|
||||
destinationType: o.destinationType,
|
||||
destinationNumber: o.destinationValue.trim(),
|
||||
label: o.label.trim() || undefined,
|
||||
positionX: node?.position.x,
|
||||
positionY: node?.position.y,
|
||||
...position,
|
||||
};
|
||||
});
|
||||
const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID);
|
||||
|
||||
@@ -8,7 +8,15 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
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 { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -47,7 +55,15 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
|
||||
</Button>
|
||||
</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>
|
||||
<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>
|
||||
</p>
|
||||
<PromptControl menu={menu} />
|
||||
<IvrFlowEditor menu={menu} extensions={extensions} />
|
||||
<IvrFlowEditor
|
||||
menu={menu}
|
||||
extensions={extensions}
|
||||
queues={queues}
|
||||
menus={menus.filter((m) => m.id !== menu.id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -143,23 +164,28 @@ function PromptControl({ menu }: { menu: IvrMenu }) {
|
||||
|
||||
interface OptionRow {
|
||||
digit: string;
|
||||
destinationNumber: string;
|
||||
destinationType: IvrOptionDestinationType;
|
||||
destinationValue: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function NewIvrMenuForm({
|
||||
extensions,
|
||||
queues,
|
||||
menus,
|
||||
existingContexts,
|
||||
onDone,
|
||||
}: {
|
||||
extensions: Extension[];
|
||||
queues: Queue[];
|
||||
menus: IvrMenu[];
|
||||
existingContexts: string[];
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [context, setContext] = useState("");
|
||||
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 [pending, startTransition] = useTransition();
|
||||
|
||||
@@ -172,7 +198,7 @@ function NewIvrMenuForm({
|
||||
|
||||
function addOption() {
|
||||
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) {
|
||||
@@ -190,8 +216,8 @@ function NewIvrMenuForm({
|
||||
setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`);
|
||||
return;
|
||||
}
|
||||
if (options.length === 0 || options.some((o) => !o.destinationNumber.trim())) {
|
||||
setError("Toda opção precisa de um dígito e um ramal de destino.");
|
||||
if (options.length === 0 || options.some((o) => !o.destinationValue.trim())) {
|
||||
setError("Toda opção precisa de um dígito e um destino selecionado.");
|
||||
return;
|
||||
}
|
||||
const digits = options.map((o) => o.digit);
|
||||
@@ -200,11 +226,24 @@ function NewIvrMenuForm({
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => ({
|
||||
digit: o.digit,
|
||||
destinationNumber: o.destinationNumber.trim(),
|
||||
label: o.label.trim() || undefined,
|
||||
}));
|
||||
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
return {
|
||||
digit: o.digit,
|
||||
destinationType: o.destinationType,
|
||||
destinationNumber: o.destinationValue.trim(),
|
||||
label: o.label.trim() || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions });
|
||||
@@ -247,7 +286,7 @@ function NewIvrMenuForm({
|
||||
</Button>
|
||||
</div>
|
||||
{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>
|
||||
<FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel>
|
||||
<Select
|
||||
@@ -264,21 +303,52 @@ function NewIvrMenuForm({
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Ramal de destino</FieldLabel>
|
||||
<FieldLabel htmlFor={`ivr-opt-${i}-type`}>Tipo</FieldLabel>
|
||||
<Select
|
||||
id={`ivr-opt-${i}-dest`}
|
||||
value={opt.destinationNumber}
|
||||
onChange={(e) => updateOption(i, { destinationNumber: e.target.value })}
|
||||
id={`ivr-opt-${i}-type`}
|
||||
value={opt.destinationType}
|
||||
onChange={(e) => updateOption(i, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
|
||||
disabled={pending}
|
||||
>
|
||||
<option value="">Selecione um ramal…</option>
|
||||
{extensions.map((ext) => (
|
||||
<option key={ext.id} value={ext.number}>
|
||||
{ext.number} — {ext.name}
|
||||
{IVR_OPTION_DESTINATION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{IVR_OPTION_DESTINATION_TYPE_LABELS[t]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</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>
|
||||
<FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel>
|
||||
<Input
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
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 { IvrView } from "./ivr-view";
|
||||
|
||||
export default async function IvrPage() {
|
||||
const session = await requireSession();
|
||||
const [menus, extensions] = await Promise.all([
|
||||
const [menus, extensions, queues] = await Promise.all([
|
||||
apiFetch<IvrMenu[]>("/ivr-menus", 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} />;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,198 @@
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Input, FieldLabel } from "@/components/ui/input";
|
||||
import { SecretReveal } from "@/components/ui/secret-reveal";
|
||||
import { AVAILABLE_CODECS, type Extension } from "@/lib/extension-types";
|
||||
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 já 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
|
||||
* atual de novo — pra reconfigurar um telefone/softphone sem invalidar
|
||||
* 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 }) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { formatDate } from "@/lib/format";
|
||||
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 {
|
||||
tenant: { name: string; code: string } | null;
|
||||
@@ -25,6 +25,8 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
|
||||
throw err;
|
||||
}
|
||||
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 (
|
||||
<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>
|
||||
</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>
|
||||
<PanelHeader
|
||||
title="Senha SIP"
|
||||
description="Cifrada em repouso — dá pra ver a atual de novo (pra reconfigurar um aparelho) ou gerar uma nova."
|
||||
title="Configuração e senha SIP"
|
||||
description="Edite os dados do ramal e veja/redefina a senha SIP no mesmo lugar."
|
||||
/>
|
||||
<div className="space-y-4 p-5">
|
||||
<RevealPasswordAction extensionId={extension.id} />
|
||||
<ResetPasswordAction extensionId={extension.id} />
|
||||
<div className="space-y-5 p-5">
|
||||
<ExtensionEditForm extension={extension} existingCallGroups={existingCallGroups} />
|
||||
<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>
|
||||
</Panel>
|
||||
|
||||
|
||||
@@ -74,8 +74,11 @@ export async function revealExtensionPassword(id: string): Promise<{ ok: true; s
|
||||
}
|
||||
|
||||
export interface UpdateExtensionInput {
|
||||
callerIdName?: string;
|
||||
callerIdNumber?: string;
|
||||
name?: string;
|
||||
callerIdName?: string | null;
|
||||
callerIdNumber?: string | null;
|
||||
context?: string;
|
||||
codecs?: string;
|
||||
maxRegistrations?: number;
|
||||
callGroup?: string | null;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function chooseTenant(tenantId: string): Promise<{ error: string }
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8,
|
||||
maxAge: 60 * 60 * 12,
|
||||
});
|
||||
|
||||
redirect("/app");
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Check, TriangleAlert } from "lucide-react";
|
||||
import { Copy, Check, TriangleAlert, X } from "lucide-react";
|
||||
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,
|
||||
* 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 }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
async function onCopy() {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setError(false);
|
||||
let ok = false;
|
||||
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 (
|
||||
@@ -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"
|
||||
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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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). */
|
||||
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 {
|
||||
id: string;
|
||||
digit: string;
|
||||
destinationType: IvrOptionDestinationType;
|
||||
destinationNumber: string;
|
||||
destinationContext: string;
|
||||
label: string | null;
|
||||
|
||||
@@ -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 {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function middleware(request: NextRequest) {
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8,
|
||||
maxAge: 60 * 60 * 12,
|
||||
});
|
||||
return response;
|
||||
} catch {
|
||||
|
||||
@@ -49,11 +49,18 @@ services:
|
||||
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
||||
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
||||
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_PASSWORD: ${ESL_PASSWORD}
|
||||
SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways
|
||||
CALLCENTER_QUEUES_DIR: /callcenter-queues
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
# Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config
|
||||
# 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
|
||||
# X-PRE-PROCESS no nosso callcenter.conf.xml (mesmo padrao acima).
|
||||
- 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:
|
||||
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
||||
interval: 10s
|
||||
@@ -99,28 +111,31 @@ services:
|
||||
# uma chamada ativa seria latência/confiabilidade desnecessárias
|
||||
# pra um prompt de poucos segundos.
|
||||
- ./data/ivr-prompts:/ivr-prompts
|
||||
# Event Socket (8021) NUNCA publicado pra REDE — só em loopback do
|
||||
# próprio host (agente.md secao 22: "NÃO pública... acesso somente
|
||||
# pela aplicação autorizada"). Achado real testando as telas de
|
||||
# Infraestrutura (platform): o comentário original aqui dizia que
|
||||
# elas SEMPRE falhariam nesta VM porque "apps/api roda fora do
|
||||
# Docker" — isso estava incompleto. `apps/api` (systemd, no host)
|
||||
# JÁ conseguia alcançar o IP do container na rede bridge sem nenhuma
|
||||
# porta publicada (`docker inspect` + `/dev/tcp` confirmam: o HOST
|
||||
# sempre alcança a rede bridge do Docker, só outras MÁQUINAS é que
|
||||
# não). O bloqueio real era `ESL_HOST=freeswitch` — um nome DNS que
|
||||
# só existe dentro da rede interna do Docker, nunca no resolver do
|
||||
# host. `127.0.0.1:8021:8021` resolve isso sem abrir a porta pra
|
||||
# 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.
|
||||
# SIP (5060) e RTP (16384-16584, range fixo no Dockerfile) publicados
|
||||
# pra rede a partir da PHASE 54 — necessário pra registrar um
|
||||
# softphone/telefone de fora da rede Docker.
|
||||
ports:
|
||||
- "127.0.0.1:8021:8021"
|
||||
- "5060:5060/udp"
|
||||
- "5060:5060/tcp"
|
||||
- "16384-16584:16384-16584/udp"
|
||||
# network_mode: host (PHASE 68) — achado real reportado pelo usuário:
|
||||
# dois ramais reais (telefones IP), atrás do MESMO roteador/NAT desta
|
||||
# rede (esta VM nunca tem IP público próprio — é sempre RFC1918 por
|
||||
# trás do NAT do escritório), ligando um pro outro. Com bridge+NAT do
|
||||
# Docker no meio, o SIP/RTP entre eles precisava atravessar uma camada
|
||||
# de tradução de endereço A MAIS antes mesmo de chegar no roteador do
|
||||
# escritório — combinado com o hairpin NAT do roteador (dois clientes
|
||||
# atrás do mesmo NAT se enxergando só pelo IP público), a chamada
|
||||
# falhava em 0ms (503, nunca chegava a sair pela rede de verdade).
|
||||
# Tirar o Docker NAT do meio (host networking) elimina essa camada
|
||||
# extra; o fix real de hairpin em si é separado — ver comentário sobre
|
||||
# `NDLB-received-in-nat-reg-contact` no Dockerfile.
|
||||
#
|
||||
# Sem `ports:` aqui (inválido com network_mode: host — o container usa
|
||||
# as portas do HOST diretamente). Segurança do Event Socket (8021,
|
||||
# agente.md secao 22) deixa de depender de publicar só em loopback
|
||||
# (não existe mais essa camada) e passa a depender inteiramente da
|
||||
# ACL `apply-inbound-acl="b2bcall_internal"` já configurada em
|
||||
# event_socket.conf.xml (127.0.0.0/8 + 172.16.0.0/12, o range do
|
||||
# bridge do Docker) — qualquer outra origem continua rejeitada mesmo
|
||||
# com a porta agora "aberta" nas interfaces do host. Os workers que
|
||||
# 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:
|
||||
test: ["CMD-SHELL", "fs_cli -p \"$$ESL_PASSWORD\" -x status | grep -q 'is ready'"]
|
||||
interval: 10s
|
||||
@@ -139,7 +154,9 @@ services:
|
||||
- redis
|
||||
- postgres
|
||||
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_PASSWORD: ${ESL_PASSWORD}
|
||||
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_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- ./data/recordings-spool:/recordings
|
||||
- ./data/object-storage-local:/data/object-storage
|
||||
@@ -174,7 +193,9 @@ services:
|
||||
- redis
|
||||
- postgres
|
||||
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_PASSWORD: ${ESL_PASSWORD}
|
||||
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;
|
||||
# não precisa do volume montado aqui, só saber o path Docker-interno.
|
||||
RECORDINGS_SPOOL_DIR: /recordings
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
ai-worker:
|
||||
build:
|
||||
|
||||
@@ -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`)
|
||||
em qualquer caso de falha — mitiga user-enumeration por diferença de tempo
|
||||
de resposta ou mensagem.
|
||||
- Access token: JWT (HS256, `jose`), TTL 15 minutos, claims `{ sub, sessionId,
|
||||
tenantId? }`.
|
||||
- Access token: JWT (HS256, `jose`), TTL 12 horas (era 15 minutos até a
|
||||
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
|
||||
SHA-256 fica salvo em `sessions.refresh_token_hash` — posse do token original
|
||||
é a prova de identidade.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
- 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
|
||||
não foi pedido) um jeito de discar um grupo de dentro do próprio
|
||||
dialplan "default" via feature code.
|
||||
|
||||
@@ -1,31 +1,47 @@
|
||||
# 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`),
|
||||
igual a `postgres` e `redis`. Nenhuma porta é publicada no host — nem 8021
|
||||
(ESL), nem SIP (5060/5080), nem RTP. Isso é intencional: ainda não existe
|
||||
nenhum tronco SIP real nem ramal externo, então não há motivo pra expor nada.
|
||||
`freeswitch` roda em `network_mode: host` — decisão tomada na PHASE 68 (era
|
||||
"pendente" antes, ver seção abaixo), pedida pelo usuário depois de dois
|
||||
ramais reais no mesmo escritório não conseguirem se ligar. `postgres`/
|
||||
`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
|
||||
`APP_DATABASE_URL`/`REDIS_URL` apontando pra `localhost` nas portas publicadas
|
||||
pelo Postgres/Redis. Ela não consegue (nem precisa, ainda) alcançar o
|
||||
FreeSWITCH.
|
||||
Com host networking, o FreeSWITCH usa a stack de rede do PRÓPRIO host
|
||||
diretamente — sem NAT/bridge do Docker no meio, `local_ip_v4` resolve pro IP
|
||||
real da interface (`10.10.32.138` nesta VM), SIP/ESL escutam direto nas
|
||||
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
|
||||
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.
|
||||
## Decisão tomada: `network_mode` do FreeSWITCH (agente.md secao 19)
|
||||
|
||||
Não decidido ainda — só vira relevante quando houver um carrier/SBC real pra
|
||||
conectar (secao 17: "FreeSWITCH não deverá depender de IP SIP público" — a
|
||||
topologia esperada é `Internet → OpenSIPS → rede SIP privada → FreeSWITCH`,
|
||||
então o FreeSWITCH em si tende a ficar em rede privada mesmo, o que favorece
|
||||
manter bridge/macvlan em vez de host).
|
||||
Historicamente listada como pendente (macvlan/ipvlan vs. host), resolvida na
|
||||
PHASE 68 a favor de **`network_mode: host`** — não por causa de um trunk
|
||||
PSTN real (a topologia `Internet → OpenSIPS → rede SIP privada →
|
||||
FreeSWITCH` da secao 17 continua valendo pra isso), mas por um problema
|
||||
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
|
||||
|
||||
|
||||
@@ -78,6 +78,83 @@ RUN sed -i \
|
||||
/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
|
||||
# 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
|
||||
|
||||
@@ -10,5 +10,58 @@
|
||||
<node type="allow" cidr="::1/128"/>
|
||||
<node type="allow" cidr="172.16.0.0/12"/>
|
||||
</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>
|
||||
</configuration>
|
||||
|
||||
@@ -6,7 +6,13 @@
|
||||
vanilla continua valendo como fallback quando "not found".
|
||||
Credenciais substituidas em runtime pelo entrypoint.sh — nunca
|
||||
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="auth-scheme" value="basic"/>
|
||||
<param name="timeout" value="5"/>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
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 interface AccessTokenClaims extends JWTPayload {
|
||||
|
||||
@@ -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';
|
||||
@@ -545,6 +545,14 @@ model IvrMenu {
|
||||
@@map("ivr_menus")
|
||||
}
|
||||
|
||||
enum IvrOptionDestinationType {
|
||||
EXTENSION
|
||||
QUEUE
|
||||
IVR
|
||||
|
||||
@@map("ivr_option_destination_type")
|
||||
}
|
||||
|
||||
model IvrMenuOption {
|
||||
id String @id @default(uuid()) @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
|
||||
|
||||
destinationNumber String @map("destination_number") // ramal real dentro do contexto abaixo
|
||||
destinationContext String @default("default") @map("destination_context")
|
||||
// PHASE 67 — achado real reportado pelo usuário: o destino de uma opção
|
||||
// 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?
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ export interface DirectoryUserParams {
|
||||
* contra a documentação do FreeSWITCH antes de close (a primeira versão
|
||||
* usava `call-group`, que não é lido por nada). */
|
||||
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_extension_id" value="${xmlEscape(params.extensionId)}"/>
|
||||
${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""}
|
||||
${params.codecs ? `<variable name="absolute_codec_string" value="${xmlEscape(params.codecs)}"/>` : ""}
|
||||
</variables>
|
||||
</user>
|
||||
</users>
|
||||
|
||||
@@ -23,6 +23,7 @@ function escapeRegexLiteral(digit: string): string {
|
||||
|
||||
export interface IvrMenuOptionInput {
|
||||
digit: string;
|
||||
destinationType: "EXTENSION" | "QUEUE" | "IVR";
|
||||
destinationNumber: string;
|
||||
destinationContext: string;
|
||||
}
|
||||
@@ -38,6 +39,14 @@ export interface IvrMenuOptionInput {
|
||||
* por uma extension nunca é enxergada por OUTRA extension na mesma
|
||||
* passada; só um `transfer` (nova consulta de dialplan) resolve isso —
|
||||
* 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(
|
||||
menu: { context: string; greeting?: string | null },
|
||||
@@ -61,14 +70,27 @@ export function buildIvrDialplanExtensions(
|
||||
],
|
||||
};
|
||||
|
||||
const branches: DialplanExtensionInput[] = options.map((opt, i) => ({
|
||||
name: `IVR opção ${opt.digit}`,
|
||||
conditionField: "destination_number",
|
||||
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`,
|
||||
continueOnFalse: false,
|
||||
order: 10 + i,
|
||||
actions: [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }],
|
||||
}));
|
||||
const branches: DialplanExtensionInput[] = options.map((opt, i) => {
|
||||
let actions: DialplanExtensionInput["actions"];
|
||||
if (opt.destinationType === "QUEUE") {
|
||||
actions = [
|
||||
{ application: "answer" },
|
||||
{ application: "callcenter", data: `${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];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user