feat(ivr): dropdown de destino (fila/outro IVR) numa opção de menu, com bloqueio de ciclo (PHASE 67)

Mesmo gap que Rotas de Entrada já tinha resolvido na PHASE 62: uma opção
dentro de um menu de IVR só sabia apontar pra ramal (bridge fixo). Novo
IvrMenuOption.destinationType (EXTENSION/QUEUE/IVR) decide a action de
cada branch em buildIvrDialplanExtensions, mesmo padrão de
buildInboundRouteXml — QUEUE vira answer+callcenter, IVR vira transfer
pro IVR_ENTRY_DESTINATION do menu alvo (efetivamente um sub-menu).

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-31 09:41:22 -03:00
parent 97ef8a6ba8
commit 432cd55adb
12 changed files with 427 additions and 78 deletions

39
TODO.md
View File

@@ -2417,6 +2417,45 @@ já em produção] que depois vai vir via sip comum ao b2bcall")
`sip-user`/`sip-domain`/`sip-server` corretos e a senha presente
(não vazia) assim que o agente com ramal vinculado abre `/app`.
## PHASE 67 — Dropdown de destino (fila/outro IVR) numa opção de IVR
(pedido do usuário: "dentro do IVR tem que dar a opção de eu colocar como
destino não apenas os ramais mas tb as filas e outra IVR, como feito na
rota de entrada")
- [x] Mesmo gap que existia em Rotas de Entrada até a PHASE 62:
`IvrMenuOption` só tinha `destinationNumber` (sempre interpretado
como ramal), sem tipo nenhum. Novo enum próprio
`IvrOptionDestinationType` (EXTENSION/QUEUE/IVR) — SEM
`CALL_GROUP` de propósito: diferente de `InboundRoute` (resolvido
por chamada, sempre fresco), o dialplan de um IVR é compilado UMA
VEZ ao salvar o menu — "quem está no grupo agora" ficaria
desatualizado até a próxima edição, comportamento silenciosamente
stale que ninguém pediu.
- [x] `buildIvrDialplanExtensions` (packages/telephony) — cada branch de
dígito decide a action pelo `destinationType`, mesmo padrão de
`buildInboundRouteXml`: EXTENSION continua `bridge` direto;
QUEUE vira `answer` + `callcenter`; IVR vira `transfer` pro
`IVR_ENTRY_DESTINATION` do contexto do menu alvo — reaproveita a
MESMA entrada que uma `InboundRoute` usa pra entrar naquele menu.
- [x] "Outro IVR" cria um grafo entre menus que nunca existia antes (uma
`InboundRoute` não é ela mesma um menu, nunca podia formar ciclo).
`assertNoIvrCycle` (novo, `ivr-menus.controller.ts`) monta o grafo
com TODOS os menus do tenant antes de compilar e rejeita
(409) qualquer combinação que criaria um ciclo — A→B→A ou até
A→A — checado tanto em `create` quanto `update`.
- [x] Frontend: os dois editores de IVR (form clássico `ivr-view.tsx` e
o editor visual `ivr-flow-editor.tsx`, PHASE 60/61) ganharam o
mesmo dropdown "Tipo" + "Destino" condicional já usado em Rotas de
Entrada — precisou buscar `queues` na `page.tsx` do IVR (só
`extensions` era buscado antes) e passar a lista de OUTROS menus
(excluindo o próprio, de propósito, pra reduzir a chance de
self-reference direto na UI — o backend segue sendo quem
garante isso de verdade).
- [x] Testado ponta a ponta com Playwright, tenant/fila/2 menus de IVR
reais criados na hora: Menu A com opção tipo Fila (aponta pra
"Fila QA"), Menu B com opção tipo Outro IVR (aponta pro Menu A) —
os dois persistiram com o tipo certo, sobrevivendo a um reload
completo da página.
---
## Riscos conhecidos