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

View File

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