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:
@@ -25,6 +25,8 @@ const PERMISSIONS: Array<{ key: string; description: string }> = [
|
||||
{ key: "trunks.manage", description: "Criar/editar troncos" },
|
||||
{ key: "inbound_routes.view", description: "Ver rotas de entrada" },
|
||||
{ key: "inbound_routes.manage", description: "Criar/editar rotas de entrada" },
|
||||
{ key: "ivr.view", description: "Ver menus de IVR" },
|
||||
{ key: "ivr.manage", description: "Criar/editar menus de IVR" },
|
||||
{ key: "agents.view", description: "Ver agentes" },
|
||||
{ key: "agents.manage", description: "Criar/editar agentes" },
|
||||
{ key: "queues.view", description: "Ver filas" },
|
||||
@@ -62,6 +64,7 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
"extensions.view",
|
||||
"trunks.view",
|
||||
"inbound_routes.view",
|
||||
"ivr.view",
|
||||
"agents.view",
|
||||
"agents.manage",
|
||||
"queues.view",
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -5,4 +5,5 @@ export * from "./directory-xml";
|
||||
export * from "./gateway-xml";
|
||||
export * from "./dialplan-xml";
|
||||
export * from "./default-dialplan";
|
||||
export * from "./ivr-xml";
|
||||
export * from "./queue-xml";
|
||||
|
||||
74
packages/telephony/src/ivr-xml.ts
Normal file
74
packages/telephony/src/ivr-xml.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { DialplanExtensionInput } from "./dialplan-xml";
|
||||
|
||||
/**
|
||||
* destination_number fixo usado pra entrar num menu de IVR (PHASE 58) —
|
||||
* toda `InboundRoute` que aponta pra um `IvrMenu` deve usar exatamente
|
||||
* este valor como `destinationNumber`, com `destinationContext` igual ao
|
||||
* `IvrMenu.context`.
|
||||
*/
|
||||
export const IVR_ENTRY_DESTINATION = "ivr_entry";
|
||||
|
||||
const DEFAULT_GREETING = "tone_stream://%(500,0,800)";
|
||||
const INVALID_TONE = "tone_stream://%(500,0,400)";
|
||||
|
||||
/** Digits válidos num menu de IVR — os mesmos que um telefone real manda. */
|
||||
export const ALLOWED_IVR_DIGITS = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"] as const;
|
||||
export type AllowedIvrDigit = (typeof ALLOWED_IVR_DIGITS)[number];
|
||||
|
||||
function escapeRegexLiteral(digit: string): string {
|
||||
// Só "*" precisa de escape entre os digits permitidos (regex especial);
|
||||
// os demais (0-9, #) já são literais seguros.
|
||||
return digit === "*" ? "\\*" : digit;
|
||||
}
|
||||
|
||||
export interface IvrMenuOptionInput {
|
||||
digit: string;
|
||||
destinationNumber: string;
|
||||
destinationContext: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compila um `IvrMenu` + suas opções nas mesmas 2 formas de `<extension>`
|
||||
* já testadas ponta a ponta com DTMF real (PHASE 56): uma entrada que
|
||||
* atende, toca o prompt, coleta 1 dígito com `play_and_get_digits` e
|
||||
* `transfer`e pro PRÓPRIO contexto usando o dígito coletado como novo
|
||||
* `destination_number` — achado real, documentado em
|
||||
* docs/INBOUND_ROUTES.md: FreeSWITCH resolve todas as condições de um
|
||||
* contexto ANTES de executar qualquer ação, então uma variable setada
|
||||
* por uma extension nunca é enxergada por OUTRA extension na mesma
|
||||
* passada; só um `transfer` (nova consulta de dialplan) resolve isso —
|
||||
* e uma extension por dígito, casando por `destination_number` normal.
|
||||
*/
|
||||
export function buildIvrDialplanExtensions(
|
||||
menu: { context: string; greeting?: string | null },
|
||||
options: IvrMenuOptionInput[],
|
||||
): DialplanExtensionInput[] {
|
||||
const greetingFile = menu.greeting?.trim() || DEFAULT_GREETING;
|
||||
|
||||
const entry: DialplanExtensionInput = {
|
||||
name: "IVR entrada",
|
||||
conditionField: "destination_number",
|
||||
conditionExpr: `^${IVR_ENTRY_DESTINATION}$`,
|
||||
continueOnFalse: false,
|
||||
order: 1,
|
||||
actions: [
|
||||
{ application: "answer" },
|
||||
{
|
||||
application: "play_and_get_digits",
|
||||
data: `1 1 3 5000 # ${greetingFile} ${INVALID_TONE} ivr_choice \\d+ 3000`,
|
||||
},
|
||||
{ application: "transfer", data: `\${ivr_choice} XML ${menu.context}` },
|
||||
],
|
||||
};
|
||||
|
||||
const branches: DialplanExtensionInput[] = options.map((opt, i) => ({
|
||||
name: `IVR opção ${opt.digit}`,
|
||||
conditionField: "destination_number",
|
||||
conditionExpr: `^${escapeRegexLiteral(opt.digit)}$`,
|
||||
continueOnFalse: false,
|
||||
order: 10 + i,
|
||||
actions: [{ application: "bridge", data: `user/${opt.destinationNumber}@\${domain_name}` }],
|
||||
}));
|
||||
|
||||
return [entry, ...branches];
|
||||
}
|
||||
Reference in New Issue
Block a user