feat(ivr): tela de autoria de menu de IVR no frontend

Pedido do usuário: "constrói a tela de IVR no frontend". Até aqui um
menu de IVR só existia se alguém escrevesse as regras à mão no editor
genérico de dialplan (o que eu fiz manualmente pra testar na PHASE 56) —
sem UI nenhuma pra isso.

`IvrMenu`/`IvrMenuOption` (RLS real): nome + contexto (derivado do nome)
+ opções (dígito → ramal + rótulo opcional). Nenhuma tabela nova pro
dialplan em si — `IvrMenusController` compila o menu inteiro em
`DialplanExtension`/`DialplanVersion` do contexto do menu
(`buildIvrDialplanExtensions`, packages/telephony) usando as MESMAS 2
formas de `<extension>` já testadas com DTMF real na PHASE 56 (entrada
com `play_and_get_digits` + `transfer` usando o dígito coletado como
novo destination_number, uma extension por dígito) — e já gera + ativa
a versão nova automaticamente, o mesmo generate+activate manual que o
editor de dialplan faz, só que embutido no create/update do menu.

`greeting` (o prompt do menu) é texto livre do tenant, então passa pela
MESMA proteção anti-RCE já aplicada em `data` de dialplan
(`IsSafeDialplanData`) — nunca pode virar `${system(...)}`.

Tela "Telefonia > IVR": lista de menus com as opções de cada um, criação
com nome/contexto (auto-gerado do nome, editável) + linhas dinâmicas de
opção (dígito + select de ramal já cadastrado + rótulo), remoção com
confirmação de 2 cliques. Mostra o contexto/destino fixo (`ivr_entry`)
que uma Rota de Entrada precisa usar pra apontar pro menu.

Testado ponta a ponta criando um menu DE VERDADE pela tela/API (não só
lendo o XML manualmente escrito antes): softphone externo discou um DID
apontado pro menu recém-criado, atendeu, tocou o prompt, colheu o dígito
com DTMF real (`uuid_recv_dtmf`) e bridged com o ramal certo — confirma
que o compilador produz XML funcionalmente idêntico ao testado
manualmente. Falta pipeline de upload/TTS de áudio, sub-menus e destino
"fila" — ver docs/INBOUND_ROUTES.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-30 17:24:07 -03:00
parent 83eb2c5aea
commit 36e85c1abf
16 changed files with 1000 additions and 6 deletions

View File

@@ -0,0 +1,49 @@
-- PHASE 58: menu de IVR (autoria por cima do dialplan genérico)
CREATE TABLE "ivr_menus" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"context" TEXT NOT NULL,
"greeting" TEXT,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "ivr_menus_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "ivr_menu_options" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"ivr_menu_id" UUID NOT NULL,
"digit" TEXT NOT NULL,
"destination_number" TEXT NOT NULL,
"destination_context" TEXT NOT NULL DEFAULT 'default',
"label" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ivr_menu_options_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ivr_menus_tenant_id_idx" ON "ivr_menus"("tenant_id");
CREATE UNIQUE INDEX "ivr_menus_tenant_id_context_key" ON "ivr_menus"("tenant_id", "context");
CREATE INDEX "ivr_menu_options_tenant_id_idx" ON "ivr_menu_options"("tenant_id");
CREATE UNIQUE INDEX "ivr_menu_options_ivr_menu_id_digit_key" ON "ivr_menu_options"("ivr_menu_id", "digit");
-- AddForeignKey
ALTER TABLE "ivr_menus" ADD CONSTRAINT "ivr_menus_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ivr_menu_options" ADD CONSTRAINT "ivr_menu_options_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "ivr_menu_options" ADD CONSTRAINT "ivr_menu_options_ivr_menu_id_fkey" FOREIGN KEY ("ivr_menu_id") REFERENCES "ivr_menus"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Tabelas de negocio tenant-scoped: RLS obrigatorio (ver docs/TENANT_ISOLATION.md).
ALTER TABLE "ivr_menus" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ivr_menus" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "ivr_menus"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "ivr_menu_options" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ivr_menu_options" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "ivr_menu_options"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

View File

@@ -66,6 +66,8 @@ model Tenant {
extensions Extension[]
trunks Trunk[]
inboundRoutes InboundRoute[]
ivrMenus IvrMenu[]
ivrMenuOptions IvrMenuOption[]
dialplanExtensions DialplanExtension[]
dialplanVersions DialplanVersion[]
queues Queue[]
@@ -480,6 +482,64 @@ model InboundRoute {
@@map("inbound_routes")
}
// Menu de IVR (PHASE 58) — tela de autoria por cima do dialplan genérico
// (docs/INBOUND_ROUTES.md, "IVR"): compilado em `DialplanExtension`/
// `DialplanVersion` do contexto `IvrMenu.context` toda vez que o menu ou
// as opções mudam (`buildIvrDialplanExtensions`, packages/telephony).
// `IvrMenu.context` é o mesmo valor que uma `InboundRoute.destinationContext`
// deve apontar; a entrada do menu sempre usa `destination_number` fixo
// (`IVR_ENTRY_DESTINATION`, "ivr_entry"), então `InboundRoute.destinationNumber`
// deve ser exatamente isso.
model IvrMenu {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String
context String
// Prompt tocado ao entrar no menu — texto livre validado contra a
// MESMA proteção anti-RCE de `data` de dialplan (secao 180): vira o
// argumento `file` de `play_and_get_digits`, então nunca pode conter
// `${funcname(...)}`. Null = usa um tom padrão (sem gravação real
// ainda — nenhuma pipeline de upload/TTS de prompt existe até aqui).
greeting String?
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
options IvrMenuOption[]
@@unique([tenantId, context])
@@index([tenantId])
@@map("ivr_menus")
}
model IvrMenuOption {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
ivrMenuId String @map("ivr_menu_id") @db.Uuid
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")
label String?
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
ivrMenu IvrMenu @relation(fields: [ivrMenuId], references: [id], onDelete: Cascade)
@@unique([ivrMenuId, digit])
@@index([tenantId])
@@map("ivr_menu_options")
}
enum DialplanVersionStatus {
DRAFT
ACTIVE