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
|
`sip-user`/`sip-domain`/`sip-server` corretos e a senha presente
|
||||||
(não vazia) assim que o agente com ramal vinculado abre `/app`.
|
(não vazia) assim que o agente com ramal vinculado abre `/app`.
|
||||||
|
|
||||||
|
## PHASE 67 — Dropdown de destino (fila/outro IVR) numa opção de IVR
|
||||||
|
(pedido do usuário: "dentro do IVR tem que dar a opção de eu colocar como
|
||||||
|
destino não apenas os ramais mas tb as filas e outra IVR, como feito na
|
||||||
|
rota de entrada")
|
||||||
|
- [x] Mesmo gap que existia em Rotas de Entrada até a PHASE 62:
|
||||||
|
`IvrMenuOption` só tinha `destinationNumber` (sempre interpretado
|
||||||
|
como ramal), sem tipo nenhum. Novo enum próprio
|
||||||
|
`IvrOptionDestinationType` (EXTENSION/QUEUE/IVR) — SEM
|
||||||
|
`CALL_GROUP` de propósito: diferente de `InboundRoute` (resolvido
|
||||||
|
por chamada, sempre fresco), o dialplan de um IVR é compilado UMA
|
||||||
|
VEZ ao salvar o menu — "quem está no grupo agora" ficaria
|
||||||
|
desatualizado até a próxima edição, comportamento silenciosamente
|
||||||
|
stale que ninguém pediu.
|
||||||
|
- [x] `buildIvrDialplanExtensions` (packages/telephony) — cada branch de
|
||||||
|
dígito decide a action pelo `destinationType`, mesmo padrão de
|
||||||
|
`buildInboundRouteXml`: EXTENSION continua `bridge` direto;
|
||||||
|
QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro
|
||||||
|
`IVR_ENTRY_DESTINATION` do contexto do menu alvo — reaproveita a
|
||||||
|
MESMA entrada que uma `InboundRoute` usa pra entrar naquele menu.
|
||||||
|
- [x] "Outro IVR" cria um grafo entre menus que nunca existia antes (uma
|
||||||
|
`InboundRoute` não é ela mesma um menu, nunca podia formar ciclo).
|
||||||
|
`assertNoIvrCycle` (novo, `ivr-menus.controller.ts`) monta o grafo
|
||||||
|
com TODOS os menus do tenant antes de compilar e rejeita
|
||||||
|
(409) qualquer combinação que criaria um ciclo — A→B→A ou até
|
||||||
|
A→A — checado tanto em `create` quanto `update`.
|
||||||
|
- [x] Frontend: os dois editores de IVR (form clássico `ivr-view.tsx` e
|
||||||
|
o editor visual `ivr-flow-editor.tsx`, PHASE 60/61) ganharam o
|
||||||
|
mesmo dropdown "Tipo" + "Destino" condicional já usado em Rotas de
|
||||||
|
Entrada — precisou buscar `queues` na `page.tsx` do IVR (só
|
||||||
|
`extensions` era buscado antes) e passar a lista de OUTROS menus
|
||||||
|
(excluindo o próprio, de propósito, pra reduzir a chance de
|
||||||
|
self-reference direto na UI — o backend segue sendo quem
|
||||||
|
garante isso de verdade).
|
||||||
|
- [x] Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR
|
||||||
|
reais criados na hora: Menu A com opção tipo Fila (aponta pra
|
||||||
|
"Fila QA"), Menu B com opção tipo Outro IVR (aponta pro Menu A) —
|
||||||
|
os dois persistiram com o tipo certo, sobrevivendo a um reload
|
||||||
|
completo da página.
|
||||||
|
|
||||||
|
## PHASE 68 — Ramal registrado mas chamada não completava / sem áudio
|
||||||
|
(reportado pelo usuário testando com 2 telefones IP reais, 1502 e 1503)
|
||||||
|
- [x] **Achado 1 (chamada não completava, 503 instantâneo)**: os dois
|
||||||
|
ramais registravam com o Contact apontando pro MESMO IP público
|
||||||
|
compartilhado desta rede (esta VM nunca tem IP público próprio —
|
||||||
|
é sempre RFC1918 atrás do NAT do escritório). Ao originar uma
|
||||||
|
chamada, o FreeSWITCH tentava mandar o INVITE de volta pra esse
|
||||||
|
IP público — hairpin NAT clássico, o roteador do escritório não
|
||||||
|
sabe refletir esse tráfego de volta pra dentro. Corrigido com
|
||||||
|
`NDLB-received-in-nat-reg-contact` (Dockerfile) — o Contact salvo
|
||||||
|
passa a ser o IP/porta REALMENTE observados no pacote (a rede
|
||||||
|
interna), não o que o telefone auto-relata.
|
||||||
|
- [x] `network_mode: host` no serviço `freeswitch` (pedido do usuário,
|
||||||
|
"sempre é problema NAT do Docker") — tira a camada de NAT/bridge
|
||||||
|
do Docker do meio. Quebra em cascata corrigida: FreeSWITCH sai da
|
||||||
|
rede bridge do compose, então (a) não resolve mais os OUTROS
|
||||||
|
containers pelo nome de serviço — `fs-config` virou
|
||||||
|
`http://127.0.0.1:8080/` (porta publicada só em loopback,
|
||||||
|
xml_curl.conf.xml) —, e (b) os workers que chamavam ESL via nome
|
||||||
|
`freeswitch` (fs-events/fs-config/predictive-dialer) passam a usar
|
||||||
|
`host.docker.internal` (+ `extra_hosts: host-gateway`, Docker
|
||||||
|
20.10+). Segurança do Event Socket deixa de depender de publicar
|
||||||
|
só em loopback (não existe mais essa camada) e passa a depender
|
||||||
|
inteiramente da ACL `b2bcall_internal` já configurada
|
||||||
|
(`apply-inbound-acl`) — confirmado que continua rejeitando
|
||||||
|
qualquer origem fora de loopback/rede do Docker.
|
||||||
|
- [x] **Achado 2 (chamada completava, mas sem áudio)**: com o registro
|
||||||
|
corrigido, a ligação passou a completar, mas sem RTP (confirmado
|
||||||
|
pelo usuário com `sngrep`). `local-network-acl="localnet.auto"`
|
||||||
|
(padrão vanilla) só cobre a subnet da PRÓPRIA interface do
|
||||||
|
FreeSWITCH — os ramais reais ficam em OUTRAS subnets do mesmo
|
||||||
|
escritório, então eram tratados como "de fora" e o SDP trocava o
|
||||||
|
RTP/SIP anunciado pelo IP público compartilhado de novo (mesmo
|
||||||
|
hairpin, agora na mídia). Corrigido com uma ACL própria
|
||||||
|
(`b2bcall_lan`, cobre todo RFC1918 — nunca alcançável de verdade
|
||||||
|
"de fora" nesta VM) referenciada em `local-network-acl`.
|
||||||
|
- [x] **Achado 3 (chamada completava e tinha áudio, mas morria sozinha
|
||||||
|
em exatos 32s)**: mesmo com áudio já corrigido, toda chamada
|
||||||
|
morria sozinha depois de exatos 32 segundos — o Timer H do
|
||||||
|
RFC 3261 (64×T1, prazo padrão de qualquer UAS esperando o ACK de
|
||||||
|
um 2xx). Só descoberto de verdade com captura de pacote real
|
||||||
|
(`tcpdump`, instalado nesta sessão — não estava no host): o
|
||||||
|
`200 OK` que o FreeSWITCH manda pro ramal que RECEBEU a chamada
|
||||||
|
ainda tinha `Contact: sip:...@191.240.175.55:5060` (o IP público
|
||||||
|
de novo, MESMO com o SDP já mostrando o IP certo) — o telefone
|
||||||
|
nunca manda o ACK de volta (não tem como chegar por ali), o
|
||||||
|
FreeSWITCH retransmite o 200 OK sozinho (0.5s/1s/2s/4s/4s...) até
|
||||||
|
desistir e derrubar a chamada. `apply-nat-acl`/`local-network-acl`
|
||||||
|
(achados 1/2) não bastam aqui — são checagens independentes, e
|
||||||
|
trocar `apply-nat-acl` pra escopo `b2bcall_lan` (testado) não
|
||||||
|
mudou nada nesse Contact. O fix de verdade: `ext-rtp-ip`/
|
||||||
|
`ext-sip-ip` (STUN, resolvem pro IP público) REMOVIDOS do profile
|
||||||
|
`internal` (Dockerfile) — sem endereço "externo" nenhum
|
||||||
|
configurado, o FreeSWITCH não tem mais como escolher errado,
|
||||||
|
cai sempre em `rtp-ip`/`sip-ip` (10.10.32.138). Único profile
|
||||||
|
afetado; `external` (reservado pra um trunk PSTN real, nunca
|
||||||
|
usado ainda) mantém os dois params intactos.
|
||||||
|
- [x] Diagnosticado ao vivo lendo `/var/log/freeswitch/freeswitch.log`
|
||||||
|
em tempo real durante as tentativas de chamada do usuário (nunca
|
||||||
|
supondo causa sem ver o log) e, pro achado 3, uma captura de
|
||||||
|
pacote real via `tcpdump` — cada achado confirmado por evidência
|
||||||
|
exata (`NORMAL_TEMPORARY_FAILURE`/503 instantâneo pro achado 1;
|
||||||
|
endereço do `c=`/RTP-IP pro achado 2; cabeçalho `Contact:` do
|
||||||
|
`200 OK` capturado byte a byte pro achado 3). Confirmado resolvido
|
||||||
|
pelo usuário com chamadas reais nos dois sentidos (1502→1503 e
|
||||||
|
1503→1502), áudio bidirecional e chamada sobrevivendo bem além
|
||||||
|
dos 32s que travavam toda tentativa antes do achado 3.
|
||||||
|
|
||||||
|
## PHASE 69 — Sessão web expirando cedo demais pra call center (pedido do
|
||||||
|
usuário: "no minimo 12 hs")
|
||||||
|
- [x] Access token JWT (`packages/auth/src/tokens.ts`) e o teto absoluto do
|
||||||
|
cookie de sessão (duplicado em `login`/`post-login`/`select-tenant`
|
||||||
|
route handlers + `middleware.ts`) subiram de 15min/8h pra 12h. O
|
||||||
|
refresh silencioso do middleware (decodifica o `exp` do JWT, renova
|
||||||
|
via `/auth/refresh` uns 60s antes de vencer) continua existindo e
|
||||||
|
deslizando a sessão a cada requisição — 12h passa a ser só o teto de
|
||||||
|
segurança pra quando esse refresh falhar (aba parada, logout em
|
||||||
|
outro lugar), não o intervalo real de renovação.
|
||||||
|
- [x] Verificado com login real: token decodificado mostra 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
|
## Riscos conhecidos
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator";
|
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
/** Codecs de áudio suportados pelo perfil Sofia `internal` (mesma lista
|
||||||
|
* usada em `packages/telephony/src/directory-xml.ts::absolute_codec_string`
|
||||||
|
* — manter as duas em sincronia). */
|
||||||
|
export const AVAILABLE_CODECS = ["PCMU", "PCMA", "OPUS", "G722", "G729", "GSM"] as const;
|
||||||
|
|
||||||
|
const CODEC_ALTERNATION = AVAILABLE_CODECS.join("|");
|
||||||
|
const CODECS_REGEX = new RegExp(`^(${CODEC_ALTERNATION})(,(${CODEC_ALTERNATION}))*$`);
|
||||||
|
|
||||||
export class CreateExtensionDto {
|
export class CreateExtensionDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" })
|
@Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" })
|
||||||
@@ -45,6 +53,11 @@ export class CreateExtensionDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateExtensionDto {
|
export class UpdateExtensionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(120)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(80)
|
@MaxLength(80)
|
||||||
@@ -55,6 +68,16 @@ export class UpdateExtensionDto {
|
|||||||
@Matches(/^[0-9]{2,20}$/)
|
@Matches(/^[0-9]{2,20}$/)
|
||||||
callerIdNumber?: string;
|
callerIdNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(80)
|
||||||
|
context?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(CODECS_REGEX, { message: `codecs deve ser uma lista separada por vírgula entre: ${AVAILABLE_CODECS.join(", ")}` })
|
||||||
|
codecs?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
|
|||||||
@@ -209,8 +209,11 @@ export class ExtensionsController {
|
|||||||
tx.extension.updateMany({
|
tx.extension.updateMany({
|
||||||
where: { id, tenantId, deletedAt: null },
|
where: { id, tenantId, deletedAt: null },
|
||||||
data: {
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||||
...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}),
|
...(dto.callerIdName !== undefined ? { callerIdName: dto.callerIdName } : {}),
|
||||||
...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}),
|
...(dto.callerIdNumber !== undefined ? { callerIdNumber: dto.callerIdNumber } : {}),
|
||||||
|
...(dto.context !== undefined ? { context: dto.context } : {}),
|
||||||
|
...(dto.codecs !== undefined ? { codecs: dto.codecs } : {}),
|
||||||
...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}),
|
...(dto.maxRegistrations !== undefined ? { maxRegistrations: dto.maxRegistrations } : {}),
|
||||||
...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}),
|
...(dto.callGroup !== undefined ? { callGroup: dto.callGroup } : {}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsStri
|
|||||||
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
|
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
|
||||||
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
|
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
|
||||||
|
|
||||||
|
const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const;
|
||||||
|
|
||||||
export class IvrMenuOptionDto {
|
export class IvrMenuOptionDto {
|
||||||
@IsIn(ALLOWED_IVR_DIGITS)
|
@IsIn(ALLOWED_IVR_DIGITS)
|
||||||
digit!: string;
|
digit!: string;
|
||||||
|
|
||||||
|
// PHASE 67 — decide como destinationNumber/destinationContext são
|
||||||
|
// interpretados (ver comentário em IvrMenuOption no schema).
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(IVR_OPTION_DESTINATION_TYPES)
|
||||||
|
destinationType?: (typeof IVR_OPTION_DESTINATION_TYPES)[number];
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
|
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
|
||||||
destinationNumber!: string;
|
destinationNumber!: string;
|
||||||
|
|||||||
@@ -49,6 +49,53 @@ function promptHostPath(tenantId: string, menuId: string): string {
|
|||||||
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`);
|
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHASE 67 — destino "Outro IVR" cria um grafo entre menus (algo que
|
||||||
|
* InboundRoute->IVR nunca tinha, já que uma rota de entrada não é ela
|
||||||
|
* mesma um menu). Sem essa checagem, dois menus apontando um pro outro
|
||||||
|
* (ou um menu apontando pra si mesmo) compilam sem erro nenhum e só
|
||||||
|
* travam a experiência do CHAMADOR depois (fica indo de um prompt pro
|
||||||
|
* outro pra sempre) — pega isso antes de compilar, não depois de alguém
|
||||||
|
* reclamar. Monta o grafo com TODOS os menus do tenant (não só os que já
|
||||||
|
* existiam antes desta operação), substituindo as arestas do menu sendo
|
||||||
|
* salvo pelas opções novas — cobre tanto create quanto update.
|
||||||
|
*/
|
||||||
|
async function assertNoIvrCycle(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
tenantId: string,
|
||||||
|
menuContext: string,
|
||||||
|
newOptions: IvrMenuOptionInput[],
|
||||||
|
): Promise<void> {
|
||||||
|
const menus = await tx.ivrMenu.findMany({
|
||||||
|
where: { tenantId, deletedAt: null },
|
||||||
|
select: { context: true, options: { select: { destinationType: true, destinationContext: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const edges = new Map<string, string[]>();
|
||||||
|
for (const m of menus) {
|
||||||
|
edges.set(
|
||||||
|
m.context,
|
||||||
|
m.options.filter((o) => o.destinationType === "IVR").map((o) => o.destinationContext),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
edges.set(
|
||||||
|
menuContext,
|
||||||
|
newOptions.filter((o) => o.destinationType === "IVR").map((o) => o.destinationContext),
|
||||||
|
);
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const stack = [...(edges.get(menuContext) ?? [])];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const next = stack.pop()!;
|
||||||
|
if (next === menuContext) {
|
||||||
|
throw new ConflictException(`Esse destino criaria um ciclo entre menus de IVR (via "${next}")`);
|
||||||
|
}
|
||||||
|
if (visited.has(next)) continue;
|
||||||
|
visited.add(next);
|
||||||
|
stack.push(...(edges.get(next) ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tela de autoria de IVR (PHASE 58, docs/INBOUND_ROUTES.md) — por cima do
|
* Tela de autoria de IVR (PHASE 58, docs/INBOUND_ROUTES.md) — por cima do
|
||||||
* editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
|
* editor genérico de dialplan (PHASE 56/57): criar/editar um `IvrMenu`
|
||||||
@@ -133,22 +180,31 @@ export class IvrMenusController {
|
|||||||
const prisma = getPrismaClient();
|
const prisma = getPrismaClient();
|
||||||
const tenantId = user.tenantId!;
|
const tenantId = user.tenantId!;
|
||||||
|
|
||||||
|
const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({
|
||||||
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType ?? "EXTENSION",
|
||||||
|
destinationNumber: o.destinationNumber,
|
||||||
|
destinationContext: o.destinationContext ?? "default",
|
||||||
|
}));
|
||||||
|
|
||||||
let menu;
|
let menu;
|
||||||
try {
|
try {
|
||||||
menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
await assertNoIvrCycle(tx, tenantId, dto.context, createOptions);
|
||||||
const created = await tx.ivrMenu.create({
|
const created = await tx.ivrMenu.create({
|
||||||
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
|
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
|
||||||
});
|
});
|
||||||
await tx.ivrMenuOption.createMany({
|
await tx.ivrMenuOption.createMany({
|
||||||
data: dto.options.map((opt) => ({
|
data: createOptions.map((opt, i) => ({
|
||||||
tenantId,
|
tenantId,
|
||||||
ivrMenuId: created.id,
|
ivrMenuId: created.id,
|
||||||
digit: opt.digit,
|
digit: opt.digit,
|
||||||
|
destinationType: opt.destinationType,
|
||||||
destinationNumber: opt.destinationNumber,
|
destinationNumber: opt.destinationNumber,
|
||||||
destinationContext: opt.destinationContext ?? "default",
|
destinationContext: opt.destinationContext,
|
||||||
label: opt.label,
|
label: dto.options[i].label,
|
||||||
positionX: opt.positionX,
|
positionX: dto.options[i].positionX,
|
||||||
positionY: opt.positionY,
|
positionY: dto.options[i].positionY,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
return created;
|
return created;
|
||||||
@@ -160,11 +216,6 @@ export class IvrMenusController {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({
|
|
||||||
digit: o.digit,
|
|
||||||
destinationNumber: o.destinationNumber,
|
|
||||||
destinationContext: o.destinationContext ?? "default",
|
|
||||||
}));
|
|
||||||
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, createOptions);
|
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, createOptions);
|
||||||
|
|
||||||
await recordAuditEvent(prisma, {
|
await recordAuditEvent(prisma, {
|
||||||
@@ -219,7 +270,24 @@ export class IvrMenusController {
|
|||||||
);
|
);
|
||||||
if (!existing) throw new NotFoundException();
|
if (!existing) throw new NotFoundException();
|
||||||
|
|
||||||
|
const options: IvrMenuOptionInput[] = dto.options
|
||||||
|
? dto.options.map((o) => ({
|
||||||
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType ?? "EXTENSION",
|
||||||
|
destinationNumber: o.destinationNumber,
|
||||||
|
destinationContext: o.destinationContext ?? "default",
|
||||||
|
}))
|
||||||
|
: existing.options.map((o) => ({
|
||||||
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType,
|
||||||
|
destinationNumber: o.destinationNumber,
|
||||||
|
destinationContext: o.destinationContext,
|
||||||
|
}));
|
||||||
|
|
||||||
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
|
||||||
|
if (dto.options) {
|
||||||
|
await assertNoIvrCycle(tx, tenantId, existing.context, options);
|
||||||
|
}
|
||||||
const updated = await tx.ivrMenu.update({
|
const updated = await tx.ivrMenu.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -232,12 +300,13 @@ export class IvrMenusController {
|
|||||||
if (dto.options) {
|
if (dto.options) {
|
||||||
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
|
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
|
||||||
await tx.ivrMenuOption.createMany({
|
await tx.ivrMenuOption.createMany({
|
||||||
data: dto.options.map((opt) => ({
|
data: dto.options.map((opt, i) => ({
|
||||||
tenantId,
|
tenantId,
|
||||||
ivrMenuId: id,
|
ivrMenuId: id,
|
||||||
digit: opt.digit,
|
digit: opt.digit,
|
||||||
|
destinationType: options[i].destinationType,
|
||||||
destinationNumber: opt.destinationNumber,
|
destinationNumber: opt.destinationNumber,
|
||||||
destinationContext: opt.destinationContext ?? "default",
|
destinationContext: options[i].destinationContext,
|
||||||
label: opt.label,
|
label: opt.label,
|
||||||
positionX: opt.positionX,
|
positionX: opt.positionX,
|
||||||
positionY: opt.positionY,
|
positionY: opt.positionY,
|
||||||
@@ -247,10 +316,6 @@ export class IvrMenusController {
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
const options: IvrMenuOptionInput[] = dto.options
|
|
||||||
? dto.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext ?? "default" }))
|
|
||||||
: existing.options.map((o) => ({ digit: o.digit, destinationNumber: o.destinationNumber, destinationContext: o.destinationContext }));
|
|
||||||
|
|
||||||
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, options);
|
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, menu, options);
|
||||||
|
|
||||||
await recordAuditEvent(prisma, {
|
await recordAuditEvent(prisma, {
|
||||||
@@ -342,6 +407,7 @@ export class IvrMenusController {
|
|||||||
|
|
||||||
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
||||||
digit: o.digit,
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType,
|
||||||
destinationNumber: o.destinationNumber,
|
destinationNumber: o.destinationNumber,
|
||||||
destinationContext: o.destinationContext,
|
destinationContext: o.destinationContext,
|
||||||
}));
|
}));
|
||||||
@@ -380,6 +446,7 @@ export class IvrMenusController {
|
|||||||
|
|
||||||
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
|
||||||
digit: o.digit,
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType,
|
||||||
destinationNumber: o.destinationNumber,
|
destinationNumber: o.destinationNumber,
|
||||||
destinationContext: o.destinationContext,
|
destinationContext: o.destinationContext,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
|
|||||||
tenantId: extension.tenantId,
|
tenantId: extension.tenantId,
|
||||||
extensionId: extension.id,
|
extensionId: extension.id,
|
||||||
callGroup: extension.callGroup,
|
callGroup: extension.callGroup,
|
||||||
|
codecs: extension.codecs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -29,7 +29,12 @@ export async function POST(request: Request) {
|
|||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 8,
|
// Call center: turno de agente pode passar de 8h — 12h é o teto
|
||||||
|
// absoluto do cookie mesmo com o refresh silencioso do middleware
|
||||||
|
// deslizando ele pra frente a cada requisição (o mesmo valor está
|
||||||
|
// duplicado em post-login/route.ts, select-tenant/actions.ts e
|
||||||
|
// middleware.ts — os 4 precisam ficar em sincronia).
|
||||||
|
maxAge: 60 * 60 * 12,
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export async function POST() {
|
|||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 8,
|
maxAge: 60 * 60 * 12,
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requireSession } from "@/lib/session";
|
import { requireSession } from "@/lib/session";
|
||||||
import { apiFetch, ApiError, API_BASE_URL } from "@/lib/api";
|
import { apiFetch, ApiError, API_BASE_URL } from "@/lib/api";
|
||||||
import type { IvrMenu } from "@/lib/callcenter-types";
|
import type { IvrMenu, IvrOptionDestinationType } from "@/lib/callcenter-types";
|
||||||
|
|
||||||
function extractErrorMessage(err: unknown): string {
|
function extractErrorMessage(err: unknown): string {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -21,7 +21,9 @@ function extractErrorMessage(err: unknown): string {
|
|||||||
|
|
||||||
export interface IvrMenuOptionInput {
|
export interface IvrMenuOptionInput {
|
||||||
digit: string;
|
digit: string;
|
||||||
|
destinationType?: IvrOptionDestinationType;
|
||||||
destinationNumber: string;
|
destinationNumber: string;
|
||||||
|
destinationContext?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
positionX?: number;
|
positionX?: number;
|
||||||
positionY?: number;
|
positionY?: number;
|
||||||
|
|||||||
@@ -19,14 +19,23 @@ import "@xyflow/react/dist/style.css";
|
|||||||
import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react";
|
import { PhoneIncoming, Plus, Save, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input, Select } from "@/components/ui/input";
|
import { Input, Select } from "@/components/ui/input";
|
||||||
import { ALLOWED_IVR_DIGITS, type IvrMenu } from "@/lib/callcenter-types";
|
import {
|
||||||
|
ALLOWED_IVR_DIGITS,
|
||||||
|
IVR_ENTRY_DESTINATION,
|
||||||
|
IVR_OPTION_DESTINATION_TYPES,
|
||||||
|
IVR_OPTION_DESTINATION_TYPE_LABELS,
|
||||||
|
type IvrMenu,
|
||||||
|
type IvrOptionDestinationType,
|
||||||
|
type Queue,
|
||||||
|
} from "@/lib/callcenter-types";
|
||||||
import type { Extension } from "@/lib/extension-types";
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions";
|
import { updateIvrMenuOptions, type IvrMenuOptionInput } from "./actions";
|
||||||
|
|
||||||
interface OptionState {
|
interface OptionState {
|
||||||
key: string;
|
key: string;
|
||||||
digit: string;
|
digit: string;
|
||||||
destinationNumber: string;
|
destinationType: IvrOptionDestinationType;
|
||||||
|
destinationValue: string;
|
||||||
label: string;
|
label: string;
|
||||||
positionX: number | null;
|
positionX: number | null;
|
||||||
positionY: number | null;
|
positionY: number | null;
|
||||||
@@ -37,6 +46,8 @@ interface EntryNodeData extends Record<string, unknown> {}
|
|||||||
interface OptionNodeData extends Record<string, unknown> {
|
interface OptionNodeData extends Record<string, unknown> {
|
||||||
option: OptionState;
|
option: OptionState;
|
||||||
extensions: Extension[];
|
extensions: Extension[];
|
||||||
|
queues: Queue[];
|
||||||
|
menus: IvrMenu[];
|
||||||
usedDigits: string[];
|
usedDigits: string[];
|
||||||
onChange: (key: string, patch: Partial<OptionState>) => void;
|
onChange: (key: string, patch: Partial<OptionState>) => void;
|
||||||
onRemove: (key: string) => void;
|
onRemove: (key: string) => void;
|
||||||
@@ -56,7 +67,7 @@ function EntryNode(_props: NodeProps<Node<EntryNodeData, "entry">>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
|
function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
|
||||||
const { option, extensions, usedDigits, onChange, onRemove } = data;
|
const { option, extensions, queues, menus, usedDigits, onChange, onRemove } = data;
|
||||||
return (
|
return (
|
||||||
<div className="w-[230px] space-y-2 rounded-lg border border-border bg-surface px-3 py-3 shadow-panel">
|
<div className="w-[230px] space-y-2 rounded-lg border border-border bg-surface px-3 py-3 shadow-panel">
|
||||||
<Handle type="target" position={Position.Top} />
|
<Handle type="target" position={Position.Top} />
|
||||||
@@ -85,16 +96,40 @@ function OptionNode({ data }: NodeProps<Node<OptionNodeData, "option">>) {
|
|||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
className="nodrag"
|
className="nodrag"
|
||||||
value={option.destinationNumber}
|
value={option.destinationType}
|
||||||
onChange={(e) => onChange(option.key, { destinationNumber: e.target.value })}
|
onChange={(e) => onChange(option.key, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
|
||||||
>
|
>
|
||||||
<option value="">Selecione um ramal…</option>
|
{IVR_OPTION_DESTINATION_TYPES.map((t) => (
|
||||||
{extensions.map((ext) => (
|
<option key={t} value={t}>
|
||||||
<option key={ext.id} value={ext.number}>
|
{IVR_OPTION_DESTINATION_TYPE_LABELS[t]}
|
||||||
{ext.number} — {ext.name}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
<Select
|
||||||
|
className="nodrag"
|
||||||
|
value={option.destinationValue}
|
||||||
|
onChange={(e) => onChange(option.key, { destinationValue: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{option.destinationType === "EXTENSION" &&
|
||||||
|
extensions.map((ext) => (
|
||||||
|
<option key={ext.id} value={ext.number}>
|
||||||
|
{ext.number} — {ext.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{option.destinationType === "QUEUE" &&
|
||||||
|
queues.map((q) => (
|
||||||
|
<option key={q.id} value={q.id}>
|
||||||
|
{q.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{option.destinationType === "IVR" &&
|
||||||
|
menus.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
className="nodrag"
|
className="nodrag"
|
||||||
value={option.label}
|
value={option.label}
|
||||||
@@ -114,6 +149,8 @@ function layoutNodes(
|
|||||||
options: OptionState[],
|
options: OptionState[],
|
||||||
entryPosition: { x: number; y: number } | null,
|
entryPosition: { x: number; y: number } | null,
|
||||||
extensions: Extension[],
|
extensions: Extension[],
|
||||||
|
queues: Queue[],
|
||||||
|
menus: IvrMenu[],
|
||||||
onChange: OptionNodeData["onChange"],
|
onChange: OptionNodeData["onChange"],
|
||||||
onRemove: OptionNodeData["onRemove"],
|
onRemove: OptionNodeData["onRemove"],
|
||||||
) {
|
) {
|
||||||
@@ -129,7 +166,7 @@ function layoutNodes(
|
|||||||
id: option.key,
|
id: option.key,
|
||||||
type: "option",
|
type: "option",
|
||||||
position: option.positionX != null && option.positionY != null ? { x: option.positionX, y: option.positionY } : { x: i * COLUMN_WIDTH, y: 160 },
|
position: option.positionX != null && option.positionY != null ? { x: option.positionX, y: option.positionY } : { x: i * COLUMN_WIDTH, y: 160 },
|
||||||
data: { option, extensions, usedDigits, onChange, onRemove },
|
data: { option, extensions, queues, menus, usedDigits, onChange, onRemove },
|
||||||
draggable: true,
|
draggable: true,
|
||||||
}));
|
}));
|
||||||
const edges: Edge[] = options.map((option) => ({
|
const edges: Edge[] = options.map((option) => ({
|
||||||
@@ -155,18 +192,29 @@ function nextKey(): string {
|
|||||||
* direto os campos (dígito/ramal/descrição); salvar recompila o
|
* direto os campos (dígito/ramal/descrição); salvar recompila o
|
||||||
* dialplan no backend exatamente como o formulário antigo fazia.
|
* dialplan no backend exatamente como o formulário antigo fazia.
|
||||||
*/
|
*/
|
||||||
export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions: Extension[] }) {
|
export function IvrFlowEditor({
|
||||||
|
menu,
|
||||||
|
extensions,
|
||||||
|
queues,
|
||||||
|
menus,
|
||||||
|
}: {
|
||||||
|
menu: IvrMenu;
|
||||||
|
extensions: Extension[];
|
||||||
|
queues: Queue[];
|
||||||
|
menus: IvrMenu[];
|
||||||
|
}) {
|
||||||
const initialOptions = useMemo<OptionState[]>(
|
const initialOptions = useMemo<OptionState[]>(
|
||||||
() =>
|
() =>
|
||||||
menu.options.map((o) => ({
|
menu.options.map((o) => ({
|
||||||
key: o.id,
|
key: o.id,
|
||||||
digit: o.digit,
|
digit: o.digit,
|
||||||
destinationNumber: o.destinationNumber,
|
destinationType: o.destinationType,
|
||||||
|
destinationValue: o.destinationType === "IVR" ? (menus.find((m) => m.context === o.destinationContext)?.id ?? "") : o.destinationNumber,
|
||||||
label: o.label ?? "",
|
label: o.label ?? "",
|
||||||
positionX: o.positionX,
|
positionX: o.positionX,
|
||||||
positionY: o.positionY,
|
positionY: o.positionY,
|
||||||
})),
|
})),
|
||||||
[menu.options],
|
[menu.options, menus],
|
||||||
);
|
);
|
||||||
const initialEntryPosition = useMemo(
|
const initialEntryPosition = useMemo(
|
||||||
() => (menu.entryPositionX != null && menu.entryPositionY != null ? { x: menu.entryPositionX, y: menu.entryPositionY } : null),
|
() => (menu.entryPositionX != null && menu.entryPositionY != null ? { x: menu.entryPositionX, y: menu.entryPositionY } : null),
|
||||||
@@ -188,9 +236,9 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
||||||
() => layoutNodes(options, initialEntryPosition, extensions, onChange, onRemove),
|
() => layoutNodes(options, initialEntryPosition, extensions, queues, menus, onChange, onRemove),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialEntryPosition só serve pro layout inicial (posição arrastada depois vive em `nodes`, nunca recomputada daqui)
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialEntryPosition só serve pro layout inicial (posição arrastada depois vive em `nodes`, nunca recomputada daqui)
|
||||||
[options, extensions, onChange, onRemove],
|
[options, extensions, queues, menus, onChange, onRemove],
|
||||||
);
|
);
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||||
@@ -212,7 +260,10 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
setSaved(false);
|
setSaved(false);
|
||||||
const usedDigits = new Set(options.map((o) => o.digit));
|
const usedDigits = new Set(options.map((o) => o.digit));
|
||||||
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
||||||
setOptions((prev) => [...prev, { key: nextKey(), digit: nextDigit, destinationNumber: "", label: "", positionX: null, positionY: null }]);
|
setOptions((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ key: nextKey(), digit: nextDigit, destinationType: "EXTENSION", destinationValue: "", label: "", positionX: null, positionY: null },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSave() {
|
function onSave() {
|
||||||
@@ -221,8 +272,8 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
setError("O menu precisa de pelo menos uma opção.");
|
setError("O menu precisa de pelo menos uma opção.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.some((o) => !o.destinationNumber.trim())) {
|
if (options.some((o) => !o.destinationValue.trim())) {
|
||||||
setError("Toda opção precisa de um ramal de destino selecionado.");
|
setError("Toda opção precisa de um destino selecionado.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const digits = options.map((o) => o.digit);
|
const digits = options.map((o) => o.digit);
|
||||||
@@ -236,12 +287,24 @@ export function IvrFlowEditor({ menu, extensions }: { menu: IvrMenu; extensions:
|
|||||||
// pelo react-flow é o estado de nós, nunca `data.option` de volta.
|
// pelo react-flow é o estado de nós, nunca `data.option` de volta.
|
||||||
const payload: IvrMenuOptionInput[] = options.map((o) => {
|
const payload: IvrMenuOptionInput[] = options.map((o) => {
|
||||||
const node = nodes.find((n) => n.id === o.key);
|
const node = nodes.find((n) => n.id === o.key);
|
||||||
|
const position = { positionX: node?.position.x, positionY: node?.position.y };
|
||||||
|
if (o.destinationType === "IVR") {
|
||||||
|
const target = menus.find((m) => m.id === o.destinationValue);
|
||||||
|
return {
|
||||||
|
digit: o.digit,
|
||||||
|
destinationType: "IVR",
|
||||||
|
destinationNumber: IVR_ENTRY_DESTINATION,
|
||||||
|
destinationContext: target?.context ?? "default",
|
||||||
|
label: o.label.trim() || undefined,
|
||||||
|
...position,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
digit: o.digit,
|
digit: o.digit,
|
||||||
destinationNumber: o.destinationNumber.trim(),
|
destinationType: o.destinationType,
|
||||||
|
destinationNumber: o.destinationValue.trim(),
|
||||||
label: o.label.trim() || undefined,
|
label: o.label.trim() || undefined,
|
||||||
positionX: node?.position.x,
|
...position,
|
||||||
positionY: node?.position.y,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID);
|
const entryNode = nodes.find((n) => n.id === ENTRY_NODE_ID);
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
import { Input, Select, FieldLabel } from "@/components/ui/input";
|
||||||
import { Pill } from "@/components/ui/pill";
|
import { Pill } from "@/components/ui/pill";
|
||||||
import { EmptyState } from "@/components/ui/table";
|
import { EmptyState } from "@/components/ui/table";
|
||||||
import { ALLOWED_IVR_DIGITS, IVR_ENTRY_DESTINATION, type IvrMenu } from "@/lib/callcenter-types";
|
import {
|
||||||
|
ALLOWED_IVR_DIGITS,
|
||||||
|
IVR_ENTRY_DESTINATION,
|
||||||
|
IVR_OPTION_DESTINATION_TYPES,
|
||||||
|
IVR_OPTION_DESTINATION_TYPE_LABELS,
|
||||||
|
type IvrMenu,
|
||||||
|
type IvrOptionDestinationType,
|
||||||
|
type Queue,
|
||||||
|
} from "@/lib/callcenter-types";
|
||||||
import type { Extension } from "@/lib/extension-types";
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
|
import { createIvrMenu, deleteIvrMenu, deleteIvrMenuPrompt, uploadIvrMenuPrompt, type IvrMenuOptionInput } from "./actions";
|
||||||
import { IvrFlowEditor } from "./ivr-flow-editor";
|
import { IvrFlowEditor } from "./ivr-flow-editor";
|
||||||
@@ -27,7 +35,7 @@ function slugifyContext(name: string): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: Extension[] }) {
|
export function IvrView({ menus, extensions, queues }: { menus: IvrMenu[]; extensions: Extension[]; queues: Queue[] }) {
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,7 +55,15 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showForm && <NewIvrMenuForm extensions={extensions} existingContexts={menus.map((m) => m.context)} onDone={() => setShowForm(false)} />}
|
{showForm && (
|
||||||
|
<NewIvrMenuForm
|
||||||
|
extensions={extensions}
|
||||||
|
queues={queues}
|
||||||
|
menus={menus}
|
||||||
|
existingContexts={menus.map((m) => m.context)}
|
||||||
|
onDone={() => setShowForm(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader title="Menus cadastrados" description={`${menus.length} menu(s) neste tenant`} />
|
<PanelHeader title="Menus cadastrados" description={`${menus.length} menu(s) neste tenant`} />
|
||||||
@@ -70,7 +86,12 @@ export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: E
|
|||||||
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
|
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
|
||||||
</p>
|
</p>
|
||||||
<PromptControl menu={menu} />
|
<PromptControl menu={menu} />
|
||||||
<IvrFlowEditor menu={menu} extensions={extensions} />
|
<IvrFlowEditor
|
||||||
|
menu={menu}
|
||||||
|
extensions={extensions}
|
||||||
|
queues={queues}
|
||||||
|
menus={menus.filter((m) => m.id !== menu.id)}
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -143,23 +164,28 @@ function PromptControl({ menu }: { menu: IvrMenu }) {
|
|||||||
|
|
||||||
interface OptionRow {
|
interface OptionRow {
|
||||||
digit: string;
|
digit: string;
|
||||||
destinationNumber: string;
|
destinationType: IvrOptionDestinationType;
|
||||||
|
destinationValue: string;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewIvrMenuForm({
|
function NewIvrMenuForm({
|
||||||
extensions,
|
extensions,
|
||||||
|
queues,
|
||||||
|
menus,
|
||||||
existingContexts,
|
existingContexts,
|
||||||
onDone,
|
onDone,
|
||||||
}: {
|
}: {
|
||||||
extensions: Extension[];
|
extensions: Extension[];
|
||||||
|
queues: Queue[];
|
||||||
|
menus: IvrMenu[];
|
||||||
existingContexts: string[];
|
existingContexts: string[];
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [context, setContext] = useState("");
|
const [context, setContext] = useState("");
|
||||||
const [contextTouched, setContextTouched] = useState(false);
|
const [contextTouched, setContextTouched] = useState(false);
|
||||||
const [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationNumber: "", label: "" }]);
|
const [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationType: "EXTENSION", destinationValue: "", label: "" }]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -172,7 +198,7 @@ function NewIvrMenuForm({
|
|||||||
|
|
||||||
function addOption() {
|
function addOption() {
|
||||||
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
|
||||||
setOptions((prev) => [...prev, { digit: nextDigit, destinationNumber: "", label: "" }]);
|
setOptions((prev) => [...prev, { digit: nextDigit, destinationType: "EXTENSION", destinationValue: "", label: "" }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeOption(index: number) {
|
function removeOption(index: number) {
|
||||||
@@ -190,8 +216,8 @@ function NewIvrMenuForm({
|
|||||||
setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`);
|
setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (options.length === 0 || options.some((o) => !o.destinationNumber.trim())) {
|
if (options.length === 0 || options.some((o) => !o.destinationValue.trim())) {
|
||||||
setError("Toda opção precisa de um dígito e um ramal de destino.");
|
setError("Toda opção precisa de um dígito e um destino selecionado.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const digits = options.map((o) => o.digit);
|
const digits = options.map((o) => o.digit);
|
||||||
@@ -200,11 +226,24 @@ function NewIvrMenuForm({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => ({
|
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => {
|
||||||
digit: o.digit,
|
if (o.destinationType === "IVR") {
|
||||||
destinationNumber: o.destinationNumber.trim(),
|
const target = menus.find((m) => m.id === o.destinationValue);
|
||||||
label: o.label.trim() || undefined,
|
return {
|
||||||
}));
|
digit: o.digit,
|
||||||
|
destinationType: "IVR",
|
||||||
|
destinationNumber: IVR_ENTRY_DESTINATION,
|
||||||
|
destinationContext: target?.context ?? "default",
|
||||||
|
label: o.label.trim() || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
digit: o.digit,
|
||||||
|
destinationType: o.destinationType,
|
||||||
|
destinationNumber: o.destinationValue.trim(),
|
||||||
|
label: o.label.trim() || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions });
|
const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions });
|
||||||
@@ -247,7 +286,7 @@ function NewIvrMenuForm({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{options.map((opt, i) => (
|
{options.map((opt, i) => (
|
||||||
<div key={i} className="grid grid-cols-1 gap-3 rounded-md border border-border p-3 sm:grid-cols-[6rem_1fr_1fr_auto]">
|
<div key={i} className="grid grid-cols-1 gap-3 rounded-md border border-border p-3 sm:grid-cols-[5rem_7rem_1fr_1fr_auto]">
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel>
|
<FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -264,21 +303,52 @@ function NewIvrMenuForm({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Ramal de destino</FieldLabel>
|
<FieldLabel htmlFor={`ivr-opt-${i}-type`}>Tipo</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
id={`ivr-opt-${i}-dest`}
|
id={`ivr-opt-${i}-type`}
|
||||||
value={opt.destinationNumber}
|
value={opt.destinationType}
|
||||||
onChange={(e) => updateOption(i, { destinationNumber: e.target.value })}
|
onChange={(e) => updateOption(i, { destinationType: e.target.value as IvrOptionDestinationType, destinationValue: "" })}
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
>
|
>
|
||||||
<option value="">Selecione um ramal…</option>
|
{IVR_OPTION_DESTINATION_TYPES.map((t) => (
|
||||||
{extensions.map((ext) => (
|
<option key={t} value={t}>
|
||||||
<option key={ext.id} value={ext.number}>
|
{IVR_OPTION_DESTINATION_TYPE_LABELS[t]}
|
||||||
{ext.number} — {ext.name}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Destino</FieldLabel>
|
||||||
|
<Select
|
||||||
|
id={`ivr-opt-${i}-dest`}
|
||||||
|
value={opt.destinationValue}
|
||||||
|
onChange={(e) => updateOption(i, { destinationValue: e.target.value })}
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
<option value="">Selecione…</option>
|
||||||
|
{opt.destinationType === "EXTENSION" &&
|
||||||
|
extensions.map((ext) => (
|
||||||
|
<option key={ext.id} value={ext.number}>
|
||||||
|
{ext.number} — {ext.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{opt.destinationType === "QUEUE" &&
|
||||||
|
queues.map((q) => (
|
||||||
|
<option key={q.id} value={q.id}>
|
||||||
|
{q.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{opt.destinationType === "IVR" &&
|
||||||
|
menus.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
{opt.destinationType === "IVR" && menus.length === 0 && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">Crie outro menu de IVR primeiro pra poder apontar pra ele.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel>
|
<FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { requireSession } from "@/lib/session";
|
import { requireSession } from "@/lib/session";
|
||||||
import { apiFetch } from "@/lib/api";
|
import { apiFetch } from "@/lib/api";
|
||||||
import type { IvrMenu } from "@/lib/callcenter-types";
|
import type { IvrMenu, Queue } from "@/lib/callcenter-types";
|
||||||
import type { Extension } from "@/lib/extension-types";
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { IvrView } from "./ivr-view";
|
import { IvrView } from "./ivr-view";
|
||||||
|
|
||||||
export default async function IvrPage() {
|
export default async function IvrPage() {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
const [menus, extensions] = await Promise.all([
|
const [menus, extensions, queues] = await Promise.all([
|
||||||
apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken),
|
apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken),
|
||||||
apiFetch<Extension[]>("/extensions", session.accessToken),
|
apiFetch<Extension[]>("/extensions", session.accessToken),
|
||||||
|
apiFetch<Queue[]>("/queues", session.accessToken),
|
||||||
]);
|
]);
|
||||||
return <IvrView menus={menus} extensions={extensions} />;
|
return <IvrView menus={menus} extensions={extensions} queues={queues} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,198 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Eye, KeyRound, Trash2 } from "lucide-react";
|
import { Eye, KeyRound, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input, FieldLabel } from "@/components/ui/input";
|
||||||
import { SecretReveal } from "@/components/ui/secret-reveal";
|
import { SecretReveal } from "@/components/ui/secret-reveal";
|
||||||
|
import { AVAILABLE_CODECS, type Extension } from "@/lib/extension-types";
|
||||||
import { resetExtensionPassword, revealExtensionPassword, deleteExtension, updateExtension } from "../actions";
|
import { resetExtensionPassword, revealExtensionPassword, deleteExtension, updateExtension } from "../actions";
|
||||||
|
|
||||||
|
/** Formulário único de edição (nome, caller ID, contexto, codecs, grupo
|
||||||
|
* de captura) — pedido do usuário: "quando clicar no ramal para editar a
|
||||||
|
* senha ou ver a senha atual já abra pra alterar" o resto da configuração
|
||||||
|
* junto, num só lugar com um botão de salvar. */
|
||||||
|
export function ExtensionEditForm({ extension, existingCallGroups }: { extension: Extension; existingCallGroups: string[] }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [name, setName] = useState(extension.name);
|
||||||
|
const [callerIdName, setCallerIdName] = useState(extension.callerIdName ?? "");
|
||||||
|
const [callerIdNumber, setCallerIdNumber] = useState(extension.callerIdNumber ?? "");
|
||||||
|
const [context, setContext] = useState(extension.context);
|
||||||
|
const [codecs, setCodecs] = useState<string[]>(extension.codecs.split(",").map((c) => c.trim()).filter(Boolean));
|
||||||
|
const [callGroup, setCallGroup] = useState(extension.callGroup ?? "");
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function toggleCodec(codec: string) {
|
||||||
|
setCodecs((prev) => (prev.includes(codec) ? prev.filter((c) => c !== codec) : [...prev, codec]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
setError(null);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setError("Dê um nome ao ramal.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (codecs.length === 0) {
|
||||||
|
setError("Selecione ao menos um codec.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await updateExtension(extension.id, {
|
||||||
|
name: name.trim(),
|
||||||
|
callerIdName: callerIdName.trim() || null,
|
||||||
|
callerIdNumber: callerIdNumber.trim() || null,
|
||||||
|
context: context.trim() || undefined,
|
||||||
|
codecs: codecs.join(","),
|
||||||
|
callGroup: callGroup.trim() || null,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
setName(extension.name);
|
||||||
|
setCallerIdName(extension.callerIdName ?? "");
|
||||||
|
setCallerIdNumber(extension.callerIdNumber ?? "");
|
||||||
|
setContext(extension.context);
|
||||||
|
setCodecs(extension.codecs.split(",").map((c) => c.trim()).filter(Boolean));
|
||||||
|
setCallGroup(extension.callGroup ?? "");
|
||||||
|
setError(null);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editing) {
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Nome</dt>
|
||||||
|
<dd className="mt-1 text-sm text-foreground">{extension.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Caller ID</dt>
|
||||||
|
<dd className="mt-1 text-sm text-foreground">
|
||||||
|
{extension.callerIdName || extension.callerIdNumber
|
||||||
|
? `${extension.callerIdName ?? ""} ${extension.callerIdNumber ? `<${extension.callerIdNumber}>` : ""}`.trim()
|
||||||
|
: "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Contexto</dt>
|
||||||
|
<dd className="mt-1 font-mono text-sm text-foreground">{extension.context}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Perfil Sofia</dt>
|
||||||
|
<dd className="mt-1 font-mono text-sm text-foreground">{extension.sofiaProfile}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Codecs</dt>
|
||||||
|
<dd className="mt-1 font-mono text-sm text-foreground">{extension.codecs}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Grupo de captura</dt>
|
||||||
|
<dd className="mt-1 text-sm text-foreground">{extension.callGroup ?? "— (nenhum)"}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||||
|
<Pencil className="h-4 w-4" aria-hidden />
|
||||||
|
Editar configuração
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="ext-edit-name">Nome</FieldLabel>
|
||||||
|
<Input id="ext-edit-name" value={name} onChange={(e) => setName(e.target.value)} disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="ext-edit-context">Contexto</FieldLabel>
|
||||||
|
<Input id="ext-edit-context" value={context} onChange={(e) => setContext(e.target.value)} className="font-mono" disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="ext-edit-cid-name">Caller ID — nome</FieldLabel>
|
||||||
|
<Input id="ext-edit-cid-name" value={callerIdName} onChange={(e) => setCallerIdName(e.target.value)} disabled={pending} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel htmlFor="ext-edit-cid-number">Caller ID — número</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="ext-edit-cid-number"
|
||||||
|
value={callerIdNumber}
|
||||||
|
onChange={(e) => setCallerIdNumber(e.target.value.replace(/[^0-9]/g, ""))}
|
||||||
|
className="font-mono"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Codecs disponíveis</FieldLabel>
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||||
|
{AVAILABLE_CODECS.map((codec) => (
|
||||||
|
<label key={codec} className="flex items-center gap-1.5 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={codecs.includes(codec)}
|
||||||
|
onChange={() => toggleCodec(codec)}
|
||||||
|
disabled={pending}
|
||||||
|
className="h-4 w-4 rounded border-input"
|
||||||
|
/>
|
||||||
|
<span className="font-mono">{codec}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:w-72">
|
||||||
|
<FieldLabel htmlFor="ext-edit-callgroup">Grupo de captura</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="ext-edit-callgroup"
|
||||||
|
list="ext-edit-callgroups"
|
||||||
|
value={callGroup}
|
||||||
|
onChange={(e) => setCallGroup(e.target.value)}
|
||||||
|
placeholder="Sem grupo"
|
||||||
|
className="font-mono"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<datalist id="ext-edit-callgroups">
|
||||||
|
{existingCallGroups.map((group) => (
|
||||||
|
<option key={group} value={group} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||||
|
Escolha um grupo 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
|
/** Diferente de `ResetPasswordAction`: nunca troca a senha, só mostra a
|
||||||
* atual de novo — pra reconfigurar um telefone/softphone sem invalidar
|
* atual de novo — pra reconfigurar um telefone/softphone sem invalidar
|
||||||
* outro aparelho já usando a mesma credencial (achado real reportado pelo
|
* outro aparelho já usando a mesma credencial (achado real reportado pelo
|
||||||
@@ -48,51 +234,6 @@ export function RevealPasswordAction({ extensionId }: { extensionId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CallGroupEditAction({ extensionId, callGroup }: { extensionId: string; callGroup: string | null }) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [value, setValue] = useState(callGroup ?? "");
|
|
||||||
const [pending, startTransition] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function onSave() {
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
const result = await updateExtension(extensionId, { callGroup: value.trim() || null });
|
|
||||||
if (!result.ok) {
|
|
||||||
setError(result.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setEditing(false);
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!editing) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="font-mono text-sm text-foreground">{callGroup ?? "— (nenhum)"}</span>
|
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input value={value} onChange={(e) => setValue(e.target.value)} placeholder="Ex.: recepcao" className="h-8 w-48 font-mono" disabled={pending} />
|
|
||||||
<Button type="button" size="sm" onClick={onSave} disabled={pending}>
|
|
||||||
{pending ? "…" : "Salvar"}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)} disabled={pending}>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ResetPasswordAction({ extensionId }: { extensionId: string }) {
|
export function ResetPasswordAction({ extensionId }: { extensionId: string }) {
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Panel, PanelHeader } from "@/components/ui/panel";
|
|||||||
import { Pill } from "@/components/ui/pill";
|
import { Pill } from "@/components/ui/pill";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
import type { Extension } from "@/lib/extension-types";
|
import type { Extension } from "@/lib/extension-types";
|
||||||
import { ResetPasswordAction, RevealPasswordAction, CallGroupEditAction, DeleteExtensionAction } from "./actions-panel";
|
import { ResetPasswordAction, RevealPasswordAction, ExtensionEditForm, DeleteExtensionAction } from "./actions-panel";
|
||||||
|
|
||||||
interface Me {
|
interface Me {
|
||||||
tenant: { name: string; code: string } | null;
|
tenant: { name: string; code: string } | null;
|
||||||
@@ -25,6 +25,8 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
const me = await apiFetch<Me>("/auth/me", session.accessToken);
|
const me = await apiFetch<Me>("/auth/me", session.accessToken);
|
||||||
|
const allExtensions = await apiFetch<Extension[]>("/extensions", session.accessToken);
|
||||||
|
const existingCallGroups = [...new Set(allExtensions.map((e) => e.callGroup).filter((g): g is string => !!g))].sort();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-2xl space-y-5">
|
<div className="mx-auto max-w-2xl space-y-5">
|
||||||
@@ -46,54 +48,25 @@ export default async function ExtensionDetailPage({ params }: { params: Promise<
|
|||||||
<span className="font-mono">{extension.domain}</span>
|
<span className="font-mono">{extension.domain}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Panel>
|
|
||||||
<PanelHeader title="Configuração" />
|
|
||||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 p-5 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Caller ID</dt>
|
|
||||||
<dd className="mt-1 text-sm text-foreground">
|
|
||||||
{extension.callerIdName || extension.callerIdNumber
|
|
||||||
? `${extension.callerIdName ?? ""} ${extension.callerIdNumber ? `<${extension.callerIdNumber}>` : ""}`.trim()
|
|
||||||
: "—"}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Contexto</dt>
|
|
||||||
<dd className="mt-1 font-mono text-sm text-foreground">{extension.context}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Perfil Sofia</dt>
|
|
||||||
<dd className="mt-1 font-mono text-sm text-foreground">{extension.sofiaProfile}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Codecs</dt>
|
|
||||||
<dd className="mt-1 font-mono text-sm text-foreground">{extension.codecs}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Registros simultâneos</dt>
|
|
||||||
<dd className="mt-1 font-mono text-sm tabular-nums text-foreground">{extension.maxRegistrations}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Grupo de captura</dt>
|
|
||||||
<dd className="mt-1 text-sm text-foreground">
|
|
||||||
<CallGroupEditAction extensionId={extension.id} callGroup={extension.callGroup} />
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Criado em</dt>
|
|
||||||
<dd className="mt-1 text-sm text-foreground">{formatDate(extension.createdAt)}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
title="Senha SIP"
|
title="Configuração e senha SIP"
|
||||||
description="Cifrada em repouso — dá pra ver a atual de novo (pra reconfigurar um aparelho) ou gerar uma nova."
|
description="Edite os dados do ramal e veja/redefina a senha SIP no mesmo lugar."
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 p-5">
|
<div className="space-y-5 p-5">
|
||||||
<RevealPasswordAction extensionId={extension.id} />
|
<ExtensionEditForm extension={extension} existingCallGroups={existingCallGroups} />
|
||||||
<ResetPasswordAction extensionId={extension.id} />
|
<div className="flex flex-wrap gap-x-6 gap-y-1 border-t border-border pt-4 text-xs text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
Registros simultâneos: <span className="font-mono tabular-nums text-foreground">{extension.maxRegistrations}</span>
|
||||||
|
</span>
|
||||||
|
<span>Criado em {formatDate(extension.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border pt-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<RevealPasswordAction extensionId={extension.id} />
|
||||||
|
<ResetPasswordAction extensionId={extension.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,11 @@ export async function revealExtensionPassword(id: string): Promise<{ ok: true; s
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateExtensionInput {
|
export interface UpdateExtensionInput {
|
||||||
callerIdName?: string;
|
name?: string;
|
||||||
callerIdNumber?: string;
|
callerIdName?: string | null;
|
||||||
|
callerIdNumber?: string | null;
|
||||||
|
context?: string;
|
||||||
|
codecs?: string;
|
||||||
maxRegistrations?: number;
|
maxRegistrations?: number;
|
||||||
callGroup?: string | null;
|
callGroup?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function chooseTenant(tenantId: string): Promise<{ error: string }
|
|||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 8,
|
maxAge: 60 * 60 * 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
redirect("/app");
|
redirect("/app");
|
||||||
|
|||||||
@@ -1,9 +1,36 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Copy, Check, TriangleAlert } from "lucide-react";
|
import { Copy, Check, TriangleAlert, X } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `navigator.clipboard` só existe em "contexto seguro" (HTTPS ou
|
||||||
|
* localhost) — achado real reportado pelo usuário: o botão de copiar não
|
||||||
|
* fazia nada (sem erro nenhum, silencioso) quando acessado pelo IP da VM
|
||||||
|
* em HTTP puro (agente.md: ambiente de laboratório sem HTTPS ainda).
|
||||||
|
* `execCommand("copy")` sobre um textarea temporário fora da tela é o
|
||||||
|
* fallback clássico que funciona em qualquer contexto, inclusive HTTP.
|
||||||
|
*/
|
||||||
|
function legacyCopy(value: string): boolean {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = value;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
textarea.style.pointerEvents = "none";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = document.execCommand("copy");
|
||||||
|
} catch {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mostra um segredo em texto puro UMA vez (senha SIP recém-gerada,
|
* Mostra um segredo em texto puro UMA vez (senha SIP recém-gerada,
|
||||||
* agente.md secao 39: "nunca mostrar novamente a senha inteira" depois
|
* agente.md secao 39: "nunca mostrar novamente a senha inteira" depois
|
||||||
@@ -12,11 +39,31 @@ import { cn } from "@/lib/utils";
|
|||||||
*/
|
*/
|
||||||
export function SecretReveal({ label, value }: { label: string; value: string }) {
|
export function SecretReveal({ label, value }: { label: string; value: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
async function onCopy() {
|
async function onCopy() {
|
||||||
await navigator.clipboard.writeText(value);
|
setError(false);
|
||||||
setCopied(true);
|
let ok = false;
|
||||||
setTimeout(() => setCopied(false), 2000);
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
ok = true;
|
||||||
|
} catch {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!ok) ok = legacyCopy(value);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} else {
|
||||||
|
// Nenhuma das duas formas funcionou — o texto já está selecionável
|
||||||
|
// (`select-all` no <code> abaixo), avisa pra copiar manualmente
|
||||||
|
// (Ctrl+C) em vez de falhar em silêncio.
|
||||||
|
setError(true);
|
||||||
|
setTimeout(() => setError(false), 4000);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,9 +82,20 @@ export function SecretReveal({ label, value }: { label: string; value: string })
|
|||||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
aria-label="Copiar senha"
|
aria-label="Copiar senha"
|
||||||
>
|
>
|
||||||
{copied ? <Check className="h-4 w-4 text-status-green" aria-hidden /> : <Copy className="h-4 w-4" aria-hidden />}
|
{copied ? (
|
||||||
|
<Check className="h-4 w-4 text-status-green" aria-hidden />
|
||||||
|
) : error ? (
|
||||||
|
<X className="h-4 w-4 text-destructive" aria-hidden />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" aria-hidden />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-2 text-xs text-destructive">
|
||||||
|
Não deu pra copiar automaticamente — selecione o texto acima e copie com Ctrl+C (ou Cmd+C).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,9 +74,19 @@ export const ALLOWED_IVR_DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9",
|
|||||||
* aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */
|
* aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */
|
||||||
export const IVR_ENTRY_DESTINATION = "ivr_entry";
|
export const IVR_ENTRY_DESTINATION = "ivr_entry";
|
||||||
|
|
||||||
|
export const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const;
|
||||||
|
export type IvrOptionDestinationType = (typeof IVR_OPTION_DESTINATION_TYPES)[number];
|
||||||
|
|
||||||
|
export const IVR_OPTION_DESTINATION_TYPE_LABELS: Record<IvrOptionDestinationType, string> = {
|
||||||
|
EXTENSION: "Ramal",
|
||||||
|
QUEUE: "Fila",
|
||||||
|
IVR: "Outro IVR",
|
||||||
|
};
|
||||||
|
|
||||||
export interface IvrMenuOption {
|
export interface IvrMenuOption {
|
||||||
id: string;
|
id: string;
|
||||||
digit: string;
|
digit: string;
|
||||||
|
destinationType: IvrOptionDestinationType;
|
||||||
destinationNumber: string;
|
destinationNumber: string;
|
||||||
destinationContext: string;
|
destinationContext: string;
|
||||||
label: string | null;
|
label: string | null;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/** Mesma lista de `apps/api/src/extensions/dto/create-extension.dto.ts::AVAILABLE_CODECS` — manter em sincronia. */
|
||||||
|
export const AVAILABLE_CODECS = ["PCMU", "PCMA", "OPUS", "G722", "G729", "GSM"] as const;
|
||||||
|
|
||||||
export interface Extension {
|
export interface Extension {
|
||||||
id: string;
|
id: string;
|
||||||
tenantId: string;
|
tenantId: string;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 8,
|
maxAge: 60 * 60 * 12,
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -49,11 +49,18 @@ services:
|
|||||||
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
||||||
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
||||||
ESL_HOST: freeswitch
|
# host.docker.internal (PHASE 68): freeswitch virou network_mode:
|
||||||
|
# host, saiu da rede bridge do compose — o nome de serviço
|
||||||
|
# "freeswitch" não resolve mais pra ele. host.docker.internal
|
||||||
|
# aponta pro HOST (onde o ESL agora escuta direto), exige o
|
||||||
|
# extra_hosts abaixo em Linux (Docker 20.10+).
|
||||||
|
ESL_HOST: host.docker.internal
|
||||||
ESL_PORT: "8021"
|
ESL_PORT: "8021"
|
||||||
ESL_PASSWORD: ${ESL_PASSWORD}
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
||||||
SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways
|
SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways
|
||||||
CALLCENTER_QUEUES_DIR: /callcenter-queues
|
CALLCENTER_QUEUES_DIR: /callcenter-queues
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
# Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config
|
# Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config
|
||||||
# escreve os XML de gateway aqui, o profile "external" ja inclui
|
# escreve os XML de gateway aqui, o profile "external" ja inclui
|
||||||
@@ -63,7 +70,12 @@ services:
|
|||||||
# escreve um XML de fila por arquivo aqui, incluido via
|
# escreve um XML de fila por arquivo aqui, incluido via
|
||||||
# X-PRE-PROCESS no nosso callcenter.conf.xml (mesmo padrao acima).
|
# X-PRE-PROCESS no nosso callcenter.conf.xml (mesmo padrao acima).
|
||||||
- freeswitch_callcenter_queues:/callcenter-queues
|
- freeswitch_callcenter_queues:/callcenter-queues
|
||||||
# Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto.
|
# Publicado só em loopback (PHASE 68): o FreeSWITCH virou
|
||||||
|
# network_mode: host e chama isto via 127.0.0.1 agora (ver
|
||||||
|
# xml_curl.conf.xml) — antes não precisava de porta nenhuma porque os
|
||||||
|
# dois estavam na mesma rede bridge do compose.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8080:8080"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -99,28 +111,31 @@ services:
|
|||||||
# uma chamada ativa seria latência/confiabilidade desnecessárias
|
# uma chamada ativa seria latência/confiabilidade desnecessárias
|
||||||
# pra um prompt de poucos segundos.
|
# pra um prompt de poucos segundos.
|
||||||
- ./data/ivr-prompts:/ivr-prompts
|
- ./data/ivr-prompts:/ivr-prompts
|
||||||
# Event Socket (8021) NUNCA publicado pra REDE — só em loopback do
|
# network_mode: host (PHASE 68) — achado real reportado pelo usuário:
|
||||||
# próprio host (agente.md secao 22: "NÃO pública... acesso somente
|
# dois ramais reais (telefones IP), atrás do MESMO roteador/NAT desta
|
||||||
# pela aplicação autorizada"). Achado real testando as telas de
|
# rede (esta VM nunca tem IP público próprio — é sempre RFC1918 por
|
||||||
# Infraestrutura (platform): o comentário original aqui dizia que
|
# trás do NAT do escritório), ligando um pro outro. Com bridge+NAT do
|
||||||
# elas SEMPRE falhariam nesta VM porque "apps/api roda fora do
|
# Docker no meio, o SIP/RTP entre eles precisava atravessar uma camada
|
||||||
# Docker" — isso estava incompleto. `apps/api` (systemd, no host)
|
# de tradução de endereço A MAIS antes mesmo de chegar no roteador do
|
||||||
# JÁ conseguia alcançar o IP do container na rede bridge sem nenhuma
|
# escritório — combinado com o hairpin NAT do roteador (dois clientes
|
||||||
# porta publicada (`docker inspect` + `/dev/tcp` confirmam: o HOST
|
# atrás do mesmo NAT se enxergando só pelo IP público), a chamada
|
||||||
# sempre alcança a rede bridge do Docker, só outras MÁQUINAS é que
|
# falhava em 0ms (503, nunca chegava a sair pela rede de verdade).
|
||||||
# não). O bloqueio real era `ESL_HOST=freeswitch` — um nome DNS que
|
# Tirar o Docker NAT do meio (host networking) elimina essa camada
|
||||||
# só existe dentro da rede interna do Docker, nunca no resolver do
|
# extra; o fix real de hairpin em si é separado — ver comentário sobre
|
||||||
# host. `127.0.0.1:8021:8021` resolve isso sem abrir a porta pra
|
# `NDLB-received-in-nat-reg-contact` no Dockerfile.
|
||||||
# ninguém além do próprio host — mesma garantia de segurança de
|
#
|
||||||
# antes, só que a aplicação autorizada agora alcança de verdade.
|
# Sem `ports:` aqui (inválido com network_mode: host — o container usa
|
||||||
# SIP (5060) e RTP (16384-16584, range fixo no Dockerfile) publicados
|
# as portas do HOST diretamente). Segurança do Event Socket (8021,
|
||||||
# pra rede a partir da PHASE 54 — necessário pra registrar um
|
# agente.md secao 22) deixa de depender de publicar só em loopback
|
||||||
# softphone/telefone de fora da rede Docker.
|
# (não existe mais essa camada) e passa a depender inteiramente da
|
||||||
ports:
|
# ACL `apply-inbound-acl="b2bcall_internal"` já configurada em
|
||||||
- "127.0.0.1:8021:8021"
|
# event_socket.conf.xml (127.0.0.0/8 + 172.16.0.0/12, o range do
|
||||||
- "5060:5060/udp"
|
# bridge do Docker) — qualquer outra origem continua rejeitada mesmo
|
||||||
- "5060:5060/tcp"
|
# com a porta agora "aberta" nas interfaces do host. Os workers que
|
||||||
- "16384-16584:16384-16584/udp"
|
# antes alcançavam via nome de serviço `freeswitch` na rede do compose
|
||||||
|
# (fs-events/fs-config/predictive-dialer) precisam agora de
|
||||||
|
# `host.docker.internal` — ver `extra_hosts` em cada um deles abaixo.
|
||||||
|
network_mode: host
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "fs_cli -p \"$$ESL_PASSWORD\" -x status | grep -q 'is ready'"]
|
test: ["CMD-SHELL", "fs_cli -p \"$$ESL_PASSWORD\" -x status | grep -q 'is ready'"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -139,7 +154,9 @@ services:
|
|||||||
- redis
|
- redis
|
||||||
- postgres
|
- postgres
|
||||||
environment:
|
environment:
|
||||||
ESL_HOST: freeswitch
|
# host.docker.internal, não "freeswitch" (PHASE 68) — ver comentário
|
||||||
|
# equivalente em fs-config acima.
|
||||||
|
ESL_HOST: host.docker.internal
|
||||||
ESL_PORT: "8021"
|
ESL_PORT: "8021"
|
||||||
ESL_PASSWORD: ${ESL_PASSWORD}
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
||||||
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
||||||
@@ -159,6 +176,8 @@ services:
|
|||||||
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
|
||||||
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
|
||||||
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-}
|
S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/recordings-spool:/recordings
|
- ./data/recordings-spool:/recordings
|
||||||
- ./data/object-storage-local:/data/object-storage
|
- ./data/object-storage-local:/data/object-storage
|
||||||
@@ -174,7 +193,9 @@ services:
|
|||||||
- redis
|
- redis
|
||||||
- postgres
|
- postgres
|
||||||
environment:
|
environment:
|
||||||
ESL_HOST: freeswitch
|
# host.docker.internal, não "freeswitch" (PHASE 68) — ver comentário
|
||||||
|
# equivalente em fs-config acima.
|
||||||
|
ESL_HOST: host.docker.internal
|
||||||
ESL_PORT: "8021"
|
ESL_PORT: "8021"
|
||||||
ESL_PASSWORD: ${ESL_PASSWORD}
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
||||||
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
||||||
@@ -188,6 +209,8 @@ services:
|
|||||||
# — quem de fato grava e' o processo FreeSWITCH, não este worker;
|
# — quem de fato grava e' o processo FreeSWITCH, não este worker;
|
||||||
# não precisa do volume montado aqui, só saber o path Docker-interno.
|
# não precisa do volume montado aqui, só saber o path Docker-interno.
|
||||||
RECORDINGS_SPOOL_DIR: /recordings
|
RECORDINGS_SPOOL_DIR: /recordings
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
ai-worker:
|
ai-worker:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ Implementado em `packages/auth`, sobre o schema criado pela migration
|
|||||||
e-mail não existe, e devolve o mesmo erro genérico (`InvalidCredentialsError`)
|
e-mail não existe, e devolve o mesmo erro genérico (`InvalidCredentialsError`)
|
||||||
em qualquer caso de falha — mitiga user-enumeration por diferença de tempo
|
em qualquer caso de falha — mitiga user-enumeration por diferença de tempo
|
||||||
de resposta ou mensagem.
|
de resposta ou mensagem.
|
||||||
- Access token: JWT (HS256, `jose`), TTL 15 minutos, claims `{ sub, sessionId,
|
- Access token: JWT (HS256, `jose`), TTL 12 horas (era 15 minutos até a
|
||||||
tenantId? }`.
|
PHASE 69 — pedido do usuário: turno de agente de call center não pode
|
||||||
|
cair no meio, o refresh silencioso do middleware existe mas 15min forçava
|
||||||
|
ele rodar a cada poucos minutos), claims `{ sub, sessionId, tenantId? }`.
|
||||||
- Refresh token: string opaca aleatória (32 bytes), **nunca** JWT. Só o
|
- Refresh token: string opaca aleatória (32 bytes), **nunca** JWT. Só o
|
||||||
SHA-256 fica salvo em `sessions.refresh_token_hash` — posse do token original
|
SHA-256 fica salvo em `sessions.refresh_token_hash` — posse do token original
|
||||||
é a prova de identidade.
|
é a prova de identidade.
|
||||||
|
|||||||
@@ -255,13 +255,48 @@ ramais) + um segundo dropdown listando as opções reais do tenant pra
|
|||||||
cada tipo (ramais cadastrados, menus de IVR, filas, ou os valores
|
cada tipo (ramais cadastrados, menus de IVR, filas, ou os valores
|
||||||
distintos de `callGroup` já usados em algum ramal).
|
distintos de `callGroup` já usados em algum ramal).
|
||||||
|
|
||||||
|
## Dropdown de destino numa opção de IVR: ramal, fila ou outro IVR (PHASE 67)
|
||||||
|
|
||||||
|
Mesmo gap da seção acima, um nível abaixo: até aqui, uma OPÇÃO dentro de
|
||||||
|
um menu de IVR só sabia apontar pra ramal (`bridge` fixo). Rota de
|
||||||
|
entrada já suportava fila/grupo pra ENTRAR num menu, mas depois de
|
||||||
|
entrar, cada dígito só discava um ramal.
|
||||||
|
|
||||||
|
`IvrMenuOption.destinationType` (novo enum próprio,
|
||||||
|
`IvrOptionDestinationType`: `EXTENSION`/`QUEUE`/`IVR`) decide a action de
|
||||||
|
cada branch em `buildIvrDialplanExtensions` (packages/telephony), mesmo
|
||||||
|
padrão do `InboundRoute` acima:
|
||||||
|
|
||||||
|
- **EXTENSION**: inalterado — `bridge` direto pro ramal.
|
||||||
|
- **QUEUE**: `answer` + `callcenter data="<queueId>@<domain>"`.
|
||||||
|
- **IVR**: `transfer` pro `IVR_ENTRY_DESTINATION` do contexto do MENU
|
||||||
|
ALVO — reaproveita a mesma entrada que uma `InboundRoute` usa pra
|
||||||
|
entrar num IVR, então um menu vira efetivamente um sub-menu de outro.
|
||||||
|
|
||||||
|
**Sem `CALL_GROUP` aqui, de propósito** (diferente de `InboundRoute`,
|
||||||
|
que tem os 4 tipos): o dialplan de um IVR é compilado UMA VEZ, ao salvar
|
||||||
|
o menu (`compileAndActivateIvrDialplan`) — não resolvido a cada chamada
|
||||||
|
como a resolução de `InboundRoute`. Um destino `CALL_GROUP` ficaria
|
||||||
|
"quem estava no grupo no momento em que o menu foi salvo por último",
|
||||||
|
uma defasagem silenciosa que ninguém pediu — melhor não oferecer do que
|
||||||
|
oferecer errado.
|
||||||
|
|
||||||
|
**Ciclos**: "Outro IVR" cria a primeira forma de um menu apontar pra
|
||||||
|
outro (uma `InboundRoute` nunca é ela mesma um menu, então nunca podia
|
||||||
|
formar ciclo). `assertNoIvrCycle` (`ivr-menus.controller.ts`) monta o
|
||||||
|
grafo com todos os menus do tenant antes de compilar e rejeita (409)
|
||||||
|
qualquer save que criaria um ciclo (A→B→A, ou até A apontando pra si
|
||||||
|
mesmo) — checado tanto em `create` quanto `update`.
|
||||||
|
|
||||||
|
Os dois editores de IVR (form clássico e o editor visual de nós, PHASE
|
||||||
|
60/61) ganharam o mesmo par de dropdowns "Tipo"/"Destino" já usado em
|
||||||
|
Rotas de Entrada. Testado ponta a ponta: menu A com opção tipo Fila,
|
||||||
|
menu B com opção tipo Outro IVR apontando pro menu A — os dois
|
||||||
|
persistiram certo depois de um reload completo da página.
|
||||||
|
|
||||||
## O que falta
|
## O que falta
|
||||||
|
|
||||||
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
|
- Sem TTS (texto→voz) — só upload de arquivo WAV já gravado.
|
||||||
- Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
|
|
||||||
destino "fila" — só ramal, dentro do contexto `default`. Rota de
|
|
||||||
entrada já suporta fila/grupo, mas um MENU de IVR ainda só bridge pra
|
|
||||||
ramal.
|
|
||||||
- Grupo de ramais só é alcançável por Rota de Entrada — não existe (e
|
- Grupo de ramais só é alcançável por Rota de Entrada — não existe (e
|
||||||
não foi pedido) um jeito de discar um grupo de dentro do próprio
|
não foi pedido) um jeito de discar um grupo de dentro do próprio
|
||||||
dialplan "default" via feature code.
|
dialplan "default" via feature code.
|
||||||
|
|||||||
@@ -1,31 +1,47 @@
|
|||||||
# Arquitetura de Rede
|
# Arquitetura de Rede
|
||||||
|
|
||||||
## Estado atual (fase FreeSWITCH inicial)
|
## Estado atual (PHASE 68 — `network_mode: host`)
|
||||||
|
|
||||||
`freeswitch` roda na rede padrão do Docker Compose (bridge, `b2bcall_default`),
|
`freeswitch` roda em `network_mode: host` — decisão tomada na PHASE 68 (era
|
||||||
igual a `postgres` e `redis`. Nenhuma porta é publicada no host — nem 8021
|
"pendente" antes, ver seção abaixo), pedida pelo usuário depois de dois
|
||||||
(ESL), nem SIP (5060/5080), nem RTP. Isso é intencional: ainda não existe
|
ramais reais no mesmo escritório não conseguirem se ligar. `postgres`/
|
||||||
nenhum tronco SIP real nem ramal externo, então não há motivo pra expor nada.
|
`redis`/`fs-config` continuam na rede bridge padrão do compose
|
||||||
|
(`b2bcall_default`); só o FreeSWITCH saiu dela.
|
||||||
|
|
||||||
`apps/api` roda hoje **direto no host** (fora do Docker), então usa
|
Com host networking, o FreeSWITCH usa a stack de rede do PRÓPRIO host
|
||||||
`APP_DATABASE_URL`/`REDIS_URL` apontando pra `localhost` nas portas publicadas
|
diretamente — sem NAT/bridge do Docker no meio, `local_ip_v4` resolve pro IP
|
||||||
pelo Postgres/Redis. Ela não consegue (nem precisa, ainda) alcançar o
|
real da interface (`10.10.32.138` nesta VM), SIP/ESL escutam direto nas
|
||||||
FreeSWITCH.
|
interfaces do host. Isso elimina uma camada de tradução de endereço que
|
||||||
|
existia antes de qualquer roteador físico entrar em cena — ver PHASE 68 no
|
||||||
|
TODO.md pro relato completo dos 3 achados (registro, áudio, e a chamada
|
||||||
|
morrendo sozinha em 32s) que motivaram e validaram essa mudança.
|
||||||
|
|
||||||
## Decisão pendente: `network_mode` do FreeSWITCH (agente.md secao 19)
|
Efeitos em cascata (o FreeSWITCH sai da rede bridge do compose):
|
||||||
|
- Não resolve mais os outros containers pelo nome de serviço — `fs-config`
|
||||||
|
agora é alcançado via `http://127.0.0.1:8080/` (porta publicada só em
|
||||||
|
loopback, `xml_curl.conf.xml`).
|
||||||
|
- Os workers que chamavam ESL via nome `freeswitch`
|
||||||
|
(fs-events/fs-config/predictive-dialer) usam `host.docker.internal` (+
|
||||||
|
`extra_hosts: host-gateway`, exige Docker 20.10+).
|
||||||
|
- Segurança do Event Socket deixa de depender de "publicar só em loopback"
|
||||||
|
(não existe mais essa camada) e passa a depender inteiramente da ACL
|
||||||
|
`b2bcall_internal` já configurada (`apply-inbound-acl`) — confirmado que
|
||||||
|
continua rejeitando qualquer origem fora de loopback/rede do Docker.
|
||||||
|
|
||||||
Quando existir um tronco SIP real (fase Trunks), será preciso decidir entre:
|
`apps/api` continua rodando **direto no host** (fora do Docker) — nada muda
|
||||||
|
pra ela, `ESL_HOST=127.0.0.1` já funcionava e continua funcionando (agora de
|
||||||
|
forma ainda mais direta, sem a camada de publish-port do Docker no meio).
|
||||||
|
|
||||||
- **`network_mode: host`**: mais simples pra SIP/RTP (sem NAT entre o
|
## Decisão tomada: `network_mode` do FreeSWITCH (agente.md secao 19)
|
||||||
container e a rede), mas perde isolamento de rede do Docker.
|
|
||||||
- **macvlan/ipvlan**: dá ao FreeSWITCH um IP próprio na rede física, sem expor
|
|
||||||
outros serviços do host: mais trabalho de configurar, melhor isolamento.
|
|
||||||
|
|
||||||
Não decidido ainda — só vira relevante quando houver um carrier/SBC real pra
|
Historicamente listada como pendente (macvlan/ipvlan vs. host), resolvida na
|
||||||
conectar (secao 17: "FreeSWITCH não deverá depender de IP SIP público" — a
|
PHASE 68 a favor de **`network_mode: host`** — não por causa de um trunk
|
||||||
topologia esperada é `Internet → OpenSIPS → rede SIP privada → FreeSWITCH`,
|
PSTN real (a topologia `Internet → OpenSIPS → rede SIP privada →
|
||||||
então o FreeSWITCH em si tende a ficar em rede privada mesmo, o que favorece
|
FreeSWITCH` da secao 17 continua valendo pra isso), mas por um problema
|
||||||
manter bridge/macvlan em vez de host).
|
concreto de NAT entre dois ramais registrados no mesmo escritório (ver
|
||||||
|
achado abaixo). `macvlan`/`ipvlan` continuam como alternativas válidas se um
|
||||||
|
dia o isolamento de rede do host virar prioridade maior que a simplicidade
|
||||||
|
atual — não implementadas, não descartadas.
|
||||||
|
|
||||||
## Achado real (PHASE 65): VM atrás de roteador, ramal externo sem áudio
|
## Achado real (PHASE 65): VM atrás de roteador, ramal externo sem áudio
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,83 @@ RUN sed -i \
|
|||||||
/etc/freeswitch/sip_profiles/internal.xml \
|
/etc/freeswitch/sip_profiles/internal.xml \
|
||||||
&& ! grep -q "force-register-domain\|force-subscription-domain\|force-register-db-domain" /etc/freeswitch/sip_profiles/internal.xml
|
&& ! grep -q "force-register-domain\|force-subscription-domain\|force-register-db-domain" /etc/freeswitch/sip_profiles/internal.xml
|
||||||
|
|
||||||
|
# Hairpin NAT entre dois ramais atrás do MESMO roteador (PHASE 68, achado
|
||||||
|
# real reportado pelo usuário: "1502 liga pra 1503 e não completa" —
|
||||||
|
# ambos registrados, mas a chamada falhava em 0ms com 503). Confirmado
|
||||||
|
# lendo `sofia status profile internal reg`: os dois ramais registram com
|
||||||
|
# Contact apontando pro MESMO IP público desta rede (a VM nunca tem IP
|
||||||
|
# público próprio — secao "essa máquina nunca terá IP público" — telefone
|
||||||
|
# e servidor share o mesmo NAT do escritório). `apply-nat-acl` (nat.auto,
|
||||||
|
# já testado funcionando) SÓ detecta que o cliente está atrás de NAT; sem
|
||||||
|
# `NDLB-received-in-nat-reg-contact`, o Contact salvo continua sendo o que
|
||||||
|
# o telefone auto-relatou (o IP público da rede) em vez do IP realmente
|
||||||
|
# observado no pacote UDP (a rede interna, ex.: 192.168.40.165) — originar
|
||||||
|
# uma chamada nova pro Contact salvo então tenta mandar o INVITE de volta
|
||||||
|
# pro IP público, que o roteador do escritório não sabe refletir de volta
|
||||||
|
# pra dentro (hairpin NAT clássico). Ligando este parâmetro, o Contact
|
||||||
|
# salvo passa a ser o IP/porta REALMENTE observados — a chamada entre dois
|
||||||
|
# ramais no mesmo escritório passa a rotear direto pela rede interna,
|
||||||
|
# nunca saindo (e voltando) pelo roteador.
|
||||||
|
RUN sed -i \
|
||||||
|
-e 's#<!-- *<param name="NDLB-received-in-nat-reg-contact" value="true"/> *-->#<param name="NDLB-received-in-nat-reg-contact" value="true"/>#' \
|
||||||
|
/etc/freeswitch/sip_profiles/internal.xml \
|
||||||
|
&& grep -q '<param name="NDLB-received-in-nat-reg-contact" value="true"/>' /etc/freeswitch/sip_profiles/internal.xml
|
||||||
|
|
||||||
|
# Continuação do achado acima: registro corrigido, mas a CHAMADA
|
||||||
|
# completava sem áudio. `local-network-acl="localnet.auto"` (vanilla) só
|
||||||
|
# cobre a subnet da própria interface do FreeSWITCH — os ramais reais
|
||||||
|
# ficam em OUTRAS subnets do mesmo escritório, então eram tratados como
|
||||||
|
# "de fora" e o SDP trocava o RTP/SIP anunciado pelo IP público
|
||||||
|
# compartilhado (mesmo hairpin de antes, agora na mídia). `b2bcall_lan`
|
||||||
|
# (acl.conf.xml, cobre todo RFC1918 — nunca alcançável de verdade "de
|
||||||
|
# fora" nesta VM, que nunca tem IP público próprio) resolve isso.
|
||||||
|
RUN sed -i \
|
||||||
|
-e 's#<param name="local-network-acl" value="localnet.auto"/>#<param name="local-network-acl" value="b2bcall_lan"/>#' \
|
||||||
|
/etc/freeswitch/sip_profiles/internal.xml \
|
||||||
|
&& grep -q '<param name="local-network-acl" value="b2bcall_lan"/>' /etc/freeswitch/sip_profiles/internal.xml
|
||||||
|
|
||||||
|
# Terceira camada do mesmo achado: com áudio corrigido, a chamada ainda
|
||||||
|
# morria sozinha em exatos 32s (Timer H do RFC 3261) — o 200 OK que o
|
||||||
|
# FreeSWITCH manda pro ramal que RECEBEU a chamada tinha
|
||||||
|
# `Contact: sip:...@191.240.175.55:5060` (IP público de novo), então o
|
||||||
|
# telefone nunca manda o ACK de volta (não tem como voltar por ali) e o
|
||||||
|
# FreeSWITCH fica retransmitindo o 200 OK sozinho até desistir. Achado
|
||||||
|
# via captura de pacote real (tcpdump), não suposição.
|
||||||
|
#
|
||||||
|
# `b2bcall_external_nat` (acl.conf.xml, sentido INVERTIDO de
|
||||||
|
# `b2bcall_lan`: só marca como NAT'd quem está de verdade fora de todo
|
||||||
|
# RFC1918 conhecido) escopa `apply-nat-acl` corretamente pra esta
|
||||||
|
# implantação — mantido por ser mais correto e não quebrar o fix de
|
||||||
|
# registro acima (testado: `NDLB-received-in-nat-reg-contact` não
|
||||||
|
# depende de `apply-nat-acl` bater, continua funcionando mesmo sem o
|
||||||
|
# `fs_nat=yes` marcado). MAS isto sozinho não resolveu o Contact do
|
||||||
|
# 200 OK — confirmado com uma segunda captura de pacote depois desta
|
||||||
|
# mudança, o Contact continuou com o IP público.
|
||||||
|
RUN sed -i \
|
||||||
|
-e 's#<param name="apply-nat-acl" value="nat.auto"/>#<param name="apply-nat-acl" value="b2bcall_external_nat"/>#' \
|
||||||
|
/etc/freeswitch/sip_profiles/internal.xml \
|
||||||
|
&& grep -q '<param name="apply-nat-acl" value="b2bcall_external_nat"/>' /etc/freeswitch/sip_profiles/internal.xml
|
||||||
|
|
||||||
|
# O fix de verdade pro Contact: `ext-rtp-ip`/`ext-sip-ip` (STUN,
|
||||||
|
# `$${external_sip_ip}` = 191.240.175.55) fazem FreeSWITCH sempre ter
|
||||||
|
# um endereço "público" pronto pra usar como identidade própria em
|
||||||
|
# respostas SIP — independente de qualquer ACL de NAT, é só o valor
|
||||||
|
# que o profile usa quando "se apresentando" externamente. Esta VM
|
||||||
|
# NUNCA tem IP público próprio de verdade (confirmado pelo usuário) —
|
||||||
|
# pro profile "internal" (só ramais registrados deste escritório,
|
||||||
|
# nunca um trunk/SBC de verdade), esse endereço "externo" nunca é
|
||||||
|
# correto, só existe pra causar exatamente este bug. Removendo os 2
|
||||||
|
# params, o profile cai automaticamente pra `rtp-ip`/`sip-ip`
|
||||||
|
# (10.10.32.138) pra tudo, sem exceção — não tem mais endereço "errado"
|
||||||
|
# pro FreeSWITCH escolher. O profile "external" (reservado pra um
|
||||||
|
# trunk PSTN real, nunca usado ainda nesta implantação) mantém
|
||||||
|
# `ext-rtp-ip`/`ext-sip-ip` intactos — só o "internal" precisava disso.
|
||||||
|
RUN sed -i \
|
||||||
|
-e '/<param name="ext-rtp-ip" value="\$\${external_rtp_ip}"\/>/d' \
|
||||||
|
-e '/<param name="ext-sip-ip" value="\$\${external_sip_ip}"\/>/d' \
|
||||||
|
/etc/freeswitch/sip_profiles/internal.xml \
|
||||||
|
&& ! grep -q 'ext-rtp-ip\|ext-sip-ip' /etc/freeswitch/sip_profiles/internal.xml
|
||||||
|
|
||||||
# RTP num range fixo e pequeno (PHASE 54, docs/EXTENSIONS.md) — o range
|
# RTP num range fixo e pequeno (PHASE 54, docs/EXTENSIONS.md) — o range
|
||||||
# vanilla default (16384-32768, ~16k portas) é inviável de publicar uma a
|
# vanilla default (16384-32768, ~16k portas) é inviável de publicar uma a
|
||||||
# uma no host; achado ao tentar registrar um softphone de fora da rede
|
# uma no host; achado ao tentar registrar um softphone de fora da rede
|
||||||
|
|||||||
@@ -10,5 +10,58 @@
|
|||||||
<node type="allow" cidr="::1/128"/>
|
<node type="allow" cidr="::1/128"/>
|
||||||
<node type="allow" cidr="172.16.0.0/12"/>
|
<node type="allow" cidr="172.16.0.0/12"/>
|
||||||
</list>
|
</list>
|
||||||
|
|
||||||
|
<!-- PHASE 68 — achado real: depois de corrigir o registro (NDLB-
|
||||||
|
received-in-nat-reg-contact, Dockerfile), a chamada completava
|
||||||
|
mas sem áudio. Causa: `local-network-acl="localnet.auto"`
|
||||||
|
(padrão vanilla) só cobre a subnet da PRÓPRIA interface do
|
||||||
|
FreeSWITCH (10.10.32.0/24, com network_mode: host) — os ramais
|
||||||
|
de verdade estão em OUTRAS subnets do mesmo escritório
|
||||||
|
(192.168.40.0/24, 192.168.44.0/24), então o FreeSWITCH os
|
||||||
|
tratava como "fora da rede local" e trocava o RTP/SIP anunciado
|
||||||
|
no SDP pelo IP público compartilhado (Ext-RTP-IP/Ext-SIP-IP,
|
||||||
|
191.240.175.55) — o mesmo hairpin NAT de antes, só que na
|
||||||
|
mídia em vez do registro. Esta VM nunca tem IP público próprio
|
||||||
|
(é sempre RFC1918 atrás do NAT do escritório), então qualquer
|
||||||
|
cliente que realmente CONSEGUE alcançar este servidor já é,
|
||||||
|
por definição, parte dessa rede privada — cobrir todo o RFC1918
|
||||||
|
aqui é seguro pra esta implantação (nunca é "de fora" de
|
||||||
|
verdade) e evita ter que listar cada subnet/VLAN do escritório
|
||||||
|
uma por uma. -->
|
||||||
|
<list name="b2bcall_lan" default="deny">
|
||||||
|
<node type="allow" cidr="10.0.0.0/8"/>
|
||||||
|
<node type="allow" cidr="172.16.0.0/12"/>
|
||||||
|
<node type="allow" cidr="192.168.0.0/16"/>
|
||||||
|
</list>
|
||||||
|
|
||||||
|
<!-- PHASE 68 (continuação) — achado real: com áudio já corrigido, o
|
||||||
|
200 OK que o FreeSWITCH manda pra um ramal como 1503 ainda tinha
|
||||||
|
`Contact: sip:1502@191.240.175.55:5060` (o IP público de novo) —
|
||||||
|
o telefone nunca manda o ACK de volta (não é dele, não sabe
|
||||||
|
voltar), o FreeSWITCH fica retransmitindo o 200 OK sozinho
|
||||||
|
(0.5s/1s/2s/4s/4s...) até desistir em exatos 32s (Timer H do
|
||||||
|
RFC 3261) e derrubar a chamada. Causa: o Contact que o
|
||||||
|
FreeSWITCH usa pra SE IDENTIFICAR numa resposta SIP é decidido
|
||||||
|
por `apply-nat-acl` (nat.auto), não por `local-network-acl` —
|
||||||
|
são duas checagens INDEPENDENTES (uma pro SDP/mídia, outra pro
|
||||||
|
cabeçalho SIP). `nat.auto` (RFC1918 exceto a própria subnet do
|
||||||
|
FreeSWITCH) continua marcando ramais de OUTRAS subnets do
|
||||||
|
escritório como "precisa de IP externo" nesta camada, mesmo já
|
||||||
|
não afetando mais o SDP. Não dá pra simplesmente trocar
|
||||||
|
`apply-nat-acl` pra `b2bcall_lan` (mesmo sentido de match de
|
||||||
|
`local-network-acl`) — isso pararia de marcar esses ramais como
|
||||||
|
NAT'd, e SEM essa marcação o `NDLB-received-in-nat-reg-contact`
|
||||||
|
(Dockerfile, o fix do REGISTRO) para de disparar, voltando o
|
||||||
|
bug original. Por isso esta é uma ACL NOVA e SEPARADA, com o
|
||||||
|
sentido INVERTIDO de `b2bcall_lan`: "NAT'd de verdade" só quem
|
||||||
|
está FORA de todo RFC1918 conhecido desta implantação — nunca
|
||||||
|
alcançável de verdade nesta VM (nunca tem IP público próprio),
|
||||||
|
mas mantém o comportamento correto se um dia existir uma
|
||||||
|
conexão genuinamente externa (trunk real via IP público). -->
|
||||||
|
<list name="b2bcall_external_nat" default="allow">
|
||||||
|
<node type="deny" cidr="10.0.0.0/8"/>
|
||||||
|
<node type="deny" cidr="172.16.0.0/12"/>
|
||||||
|
<node type="deny" cidr="192.168.0.0/16"/>
|
||||||
|
</list>
|
||||||
</network-lists>
|
</network-lists>
|
||||||
</configuration>
|
</configuration>
|
||||||
|
|||||||
@@ -6,7 +6,13 @@
|
|||||||
vanilla continua valendo como fallback quando "not found".
|
vanilla continua valendo como fallback quando "not found".
|
||||||
Credenciais substituidas em runtime pelo entrypoint.sh — nunca
|
Credenciais substituidas em runtime pelo entrypoint.sh — nunca
|
||||||
ficam de verdade na imagem (mesmo padrao do ESL_PASSWORD). -->
|
ficam de verdade na imagem (mesmo padrao do ESL_PASSWORD). -->
|
||||||
<param name="gateway-url" value="http://fs-config:8080/" bindings="directory|dialplan"/>
|
<!-- 127.0.0.1, nao "fs-config" (PHASE 68): com o FreeSWITCH em
|
||||||
|
network_mode: host, ele nao esta mais na rede bridge do
|
||||||
|
compose, entao o DNS embutido do Docker (nomes de servico) nao
|
||||||
|
resolve mais pra ele. fs-config publica 8080 so em loopback do
|
||||||
|
host (docker-compose.yml) — o FreeSWITCH, agora usando a
|
||||||
|
propria stack de rede do host, alcanca por ali. -->
|
||||||
|
<param name="gateway-url" value="http://127.0.0.1:8080/" bindings="directory|dialplan"/>
|
||||||
<param name="gateway-credentials" value="__FS_CONFIG_USER__:__FS_CONFIG_PASSWORD__"/>
|
<param name="gateway-credentials" value="__FS_CONFIG_USER__:__FS_CONFIG_PASSWORD__"/>
|
||||||
<param name="auth-scheme" value="basic"/>
|
<param name="auth-scheme" value="basic"/>
|
||||||
<param name="timeout" value="5"/>
|
<param name="timeout" value="5"/>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createHash, randomBytes } from "node:crypto";
|
import { createHash, randomBytes } from "node:crypto";
|
||||||
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
|
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
|
||||||
|
|
||||||
const ACCESS_TOKEN_TTL = "15m";
|
// Call center: agente logado o turno inteiro sem cair no meio de uma
|
||||||
|
// ligação — 15min (default anterior) forçava refresh silencioso a cada
|
||||||
|
// poucos minutos, arriscando um gap bem no meio de uma chamada.
|
||||||
|
const ACCESS_TOKEN_TTL = "12h";
|
||||||
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 dias
|
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 dias
|
||||||
|
|
||||||
export interface AccessTokenClaims extends JWTPayload {
|
export interface AccessTokenClaims extends JWTPayload {
|
||||||
|
|||||||
@@ -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")
|
@@map("ivr_menus")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum IvrOptionDestinationType {
|
||||||
|
EXTENSION
|
||||||
|
QUEUE
|
||||||
|
IVR
|
||||||
|
|
||||||
|
@@map("ivr_option_destination_type")
|
||||||
|
}
|
||||||
|
|
||||||
model IvrMenuOption {
|
model IvrMenuOption {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
tenantId String @map("tenant_id") @db.Uuid
|
tenantId String @map("tenant_id") @db.Uuid
|
||||||
@@ -552,8 +560,27 @@ model IvrMenuOption {
|
|||||||
|
|
||||||
digit String // "0".."9", "*" ou "#" — validado na API, é o que vira o regex da extension de branching
|
digit String // "0".."9", "*" ou "#" — validado na API, é o que vira o regex da extension de branching
|
||||||
|
|
||||||
destinationNumber String @map("destination_number") // ramal real dentro do contexto abaixo
|
// PHASE 67 — achado real reportado pelo usuário: o destino de uma opção
|
||||||
destinationContext String @default("default") @map("destination_context")
|
// de IVR só podia ser ramal, sem dropdown de fila/outro menu, igual o
|
||||||
|
// gap que existia em Rotas de Entrada até a PHASE 62. Mesmo `destination
|
||||||
|
// Type` decide como `buildIvrDialplanExtensions` (packages/telephony)
|
||||||
|
// interpreta os 2 campos abaixo:
|
||||||
|
// EXTENSION -> destinationNumber é o número do ramal (bridge, igual
|
||||||
|
// sempre foi); destinationContext não usado
|
||||||
|
// QUEUE -> destinationNumber é o Queue.id (answer + callcenter,
|
||||||
|
// mesmo padrão de InboundRoute); destinationContext não usado
|
||||||
|
// IVR -> destinationNumber vira sempre IVR_ENTRY_DESTINATION;
|
||||||
|
// destinationContext é o IvrMenu.context do menu alvo
|
||||||
|
// (transfer pro próprio contexto, igual InboundRoute
|
||||||
|
// encaminha pra um IVR). Sem CALL_GROUP aqui de propósito:
|
||||||
|
// diferente de InboundRoute, o dialplan de um IVR é
|
||||||
|
// compilado uma vez ao salvar (não resolvido por chamada),
|
||||||
|
// então "quem está no grupo agora" ficaria desatualizado
|
||||||
|
// até a próxima edição — não pedido pelo usuário, não
|
||||||
|
// implementado.
|
||||||
|
destinationType IvrOptionDestinationType @default(EXTENSION) @map("destination_type")
|
||||||
|
destinationNumber String @map("destination_number") // ramal real dentro do contexto abaixo
|
||||||
|
destinationContext String @default("default") @map("destination_context")
|
||||||
|
|
||||||
label String?
|
label String?
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export interface DirectoryUserParams {
|
|||||||
* contra a documentação do FreeSWITCH antes de close (a primeira versão
|
* contra a documentação do FreeSWITCH antes de close (a primeira versão
|
||||||
* usava `call-group`, que não é lido por nada). */
|
* usava `call-group`, que não é lido por nada). */
|
||||||
callGroup?: string | null;
|
callGroup?: string | null;
|
||||||
|
/** Lista de codecs de áudio permitidos pro ramal, separados por vírgula
|
||||||
|
* (ex.: "PCMU,PCMA,OPUS") — vira `absolute_codec_string`, a variável
|
||||||
|
* que o FreeSWITCH usa pra restringir/ordenar os codecs oferecidos na
|
||||||
|
* negociação SDP pra este usuário especificamente. */
|
||||||
|
codecs?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +72,7 @@ export function buildDirectoryUserXml(params: DirectoryUserParams): string {
|
|||||||
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
|
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
|
||||||
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
|
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
|
||||||
${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""}
|
${params.callGroup ? `<variable name="callgroup" value="${xmlEscape(params.callGroup)}"/>` : ""}
|
||||||
|
${params.codecs ? `<variable name="absolute_codec_string" value="${xmlEscape(params.codecs)}"/>` : ""}
|
||||||
</variables>
|
</variables>
|
||||||
</user>
|
</user>
|
||||||
</users>
|
</users>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function escapeRegexLiteral(digit: string): string {
|
|||||||
|
|
||||||
export interface IvrMenuOptionInput {
|
export interface IvrMenuOptionInput {
|
||||||
digit: string;
|
digit: string;
|
||||||
|
destinationType: "EXTENSION" | "QUEUE" | "IVR";
|
||||||
destinationNumber: string;
|
destinationNumber: string;
|
||||||
destinationContext: string;
|
destinationContext: string;
|
||||||
}
|
}
|
||||||
@@ -38,6 +39,14 @@ export interface IvrMenuOptionInput {
|
|||||||
* por uma extension nunca é enxergada por OUTRA extension na mesma
|
* por uma extension nunca é enxergada por OUTRA extension na mesma
|
||||||
* passada; só um `transfer` (nova consulta de dialplan) resolve isso —
|
* passada; só um `transfer` (nova consulta de dialplan) resolve isso —
|
||||||
* e uma extension por dígito, casando por `destination_number` normal.
|
* e uma extension por dígito, casando por `destination_number` normal.
|
||||||
|
*
|
||||||
|
* PHASE 67 (dropdown de destino real: ramal/fila/outro IVR) — cada branch
|
||||||
|
* decide a action pelo `destinationType`, mesmo padrão de
|
||||||
|
* `buildInboundRouteXml`: EXTENSION continua `bridge` direto pro ramal;
|
||||||
|
* QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro
|
||||||
|
* `IVR_ENTRY_DESTINATION` do contexto alvo (reaproveita a MESMA entrada
|
||||||
|
* que qualquer chamada de fora usa pra entrar naquele menu). Sem
|
||||||
|
* CALL_GROUP aqui — motivo no comentário do schema (`IvrMenuOption`).
|
||||||
*/
|
*/
|
||||||
export function buildIvrDialplanExtensions(
|
export function buildIvrDialplanExtensions(
|
||||||
menu: { context: string; greeting?: string | null },
|
menu: { context: string; greeting?: string | null },
|
||||||
@@ -61,14 +70,27 @@ export function buildIvrDialplanExtensions(
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const branches: DialplanExtensionInput[] = options.map((opt, i) => ({
|
const branches: DialplanExtensionInput[] = options.map((opt, i) => {
|
||||||
name: `IVR opção ${opt.digit}`,
|
let actions: DialplanExtensionInput["actions"];
|
||||||
conditionField: "destination_number",
|
if (opt.destinationType === "QUEUE") {
|
||||||
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`,
|
actions = [
|
||||||
continueOnFalse: false,
|
{ application: "answer" },
|
||||||
order: 10 + i,
|
{ application: "callcenter", data: `${opt.destinationNumber}@\${domain_name}` },
|
||||||
actions: [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }],
|
];
|
||||||
}));
|
} else if (opt.destinationType === "IVR") {
|
||||||
|
actions = [{ application: "transfer", data: `${IVR_ENTRY_DESTINATION} XML ${opt.destinationContext}` }];
|
||||||
|
} else {
|
||||||
|
actions = [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: `IVR opção ${opt.digit}`,
|
||||||
|
conditionField: "destination_number",
|
||||||
|
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`,
|
||||||
|
continueOnFalse: false,
|
||||||
|
order: 10 + i,
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return [entry, ...branches];
|
return [entry, ...branches];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user