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

29
TODO.md
View File

@@ -2117,6 +2117,35 @@ reportado pelo usuário: "a ligacao entre ramais nao esta funcionando")
contexto `default` já nasce `ACTIVE` com as 2 regras. Tenant de contexto `default` já nasce `ACTIVE` com as 2 regras. Tenant de
teste suspenso ao final (sem endpoint de delete de tenant) teste suspenso ao final (sem endpoint de delete de tenant)
## PHASE 58 — Tela de autoria de IVR (pedido do usuário: "constrói a
tela de IVR no frontend")
- [x] `IvrMenu`/`IvrMenuOption` (novo, RLS real) — nome + contexto
(derivado do nome) + opções (dígito → ramal + rótulo). Nenhuma
tabela nova pro dialplan em si: `IvrMenusController` compila o
menu em `DialplanExtension`/`DialplanVersion` do contexto do menu
(mesmas 2 formas de `<extension>` já testadas com DTMF real na
PHASE 56 — entrada com `play_and_get_digits` + `transfer`, uma
extension por dígito) e já gera + ativa a versão nova, automático
- [x] `greeting` (prompt do menu) passa pela MESMA proteção anti-RCE já
aplicada em `data` de dialplan (`IsSafeDialplanData`) — é texto
livre do tenant, não pode virar `${system(...)}`
- [x] Tela "Telefonia > IVR": lista de menus com as opções de cada um,
criação com nome/contexto (auto-gerado, editável) + linhas
dinâmicas de opção (dígito + select de ramal existente + rótulo
opcional), 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
- [x] Testado ponta a ponta criando um menu DE VERDADE pela 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 na PHASE 56
- [ ] Sem pipeline de upload/TTS de prompt de áudio (texto livre/tom
padrão só); sem sub-menu (IVR dentro de IVR) nem destino "fila";
"Rotas de Entrada" ainda não tem um seletor dedicado de "IVR" como
destino (usuário copia contexto/`ivr_entry` da tela de IVR)
--- ---
## Riscos conhecidos ## Riscos conhecidos

View File

@@ -4,6 +4,7 @@ import { AuthModule } from "./auth/auth.module";
import { ExtensionsModule } from "./extensions/extensions.module"; import { ExtensionsModule } from "./extensions/extensions.module";
import { TrunksModule } from "./trunks/trunks.module"; import { TrunksModule } from "./trunks/trunks.module";
import { InboundRoutesModule } from "./inbound-routes/inbound-routes.module"; import { InboundRoutesModule } from "./inbound-routes/inbound-routes.module";
import { IvrModule } from "./ivr/ivr.module";
import { DialplanModule } from "./dialplan/dialplan.module"; import { DialplanModule } from "./dialplan/dialplan.module";
import { QueuesModule } from "./queues/queues.module"; import { QueuesModule } from "./queues/queues.module";
import { AgentsModule } from "./agents/agents.module"; import { AgentsModule } from "./agents/agents.module";
@@ -31,6 +32,7 @@ import { PlansModule } from "./plans/plans.module";
ExtensionsModule, ExtensionsModule,
TrunksModule, TrunksModule,
InboundRoutesModule, InboundRoutesModule,
IvrModule,
DialplanModule, DialplanModule,
QueuesModule, QueuesModule,
AgentsModule, AgentsModule,

View File

@@ -0,0 +1,69 @@
import { Type } from "class-transformer";
import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
export class IvrMenuOptionDto {
@IsIn(ALLOWED_IVR_DIGITS)
digit!: string;
@IsString()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
destinationNumber!: string;
@IsOptional()
@IsString()
@MaxLength(80)
destinationContext?: string;
@IsOptional()
@IsString()
@MaxLength(80)
label?: string;
}
export class CreateIvrMenuDto {
@IsString()
@MaxLength(80)
name!: string;
// Vira o `context` do dialplan compilado — derivado do nome no
// frontend (slug), mas validado aqui como qualquer outro context.
@IsString()
@Matches(/^[a-z0-9-]{1,60}$/, { message: "context deve ser minúsculo, com letras/números/hífen (1 a 60 caracteres)" })
context!: string;
@IsOptional()
@IsString()
@MaxLength(500)
@IsSafeDialplanData({ message: "greeting usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
greeting?: string;
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(12)
@ValidateNested({ each: true })
@Type(() => IvrMenuOptionDto)
options!: IvrMenuOptionDto[];
}
export class UpdateIvrMenuDto {
@IsOptional()
@IsString()
@MaxLength(80)
name?: string;
@IsOptional()
@IsString()
@MaxLength(500)
@IsSafeDialplanData({ message: "greeting usa uma função não permitida (ver docs/EXTENSIONS.md — nunca system/bg_system/curl/db/lua/shell)" })
greeting?: string;
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(12)
@ValidateNested({ each: true })
@Type(() => IvrMenuOptionDto)
options?: IvrMenuOptionDto[];
}

View File

@@ -0,0 +1,259 @@
import {
BadRequestException,
Body,
ConflictException,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { XMLValidator } from "fast-xml-parser";
import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { buildDialplanXml, buildIvrDialplanExtensions, type IvrMenuOptionInput } from "@b2bcall/telephony";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateIvrMenuDto, UpdateIvrMenuDto } from "./dto/create-ivr-menu.dto";
/**
* 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`
* recompila e reativa uma versão nova do contexto correspondente, o
* mesmo fluxo generate+activate que o editor manual faz, só que
* automático. Uma `InboundRoute` aponta pra cá com
* `destinationContext = IvrMenu.context` e
* `destinationNumber = IVR_ENTRY_DESTINATION` ("ivr_entry").
*/
async function compileAndActivateIvrDialplan(
prisma: ReturnType<typeof getPrismaClient>,
tenantId: string,
userId: string,
menu: { context: string; greeting: string | null },
options: IvrMenuOptionInput[],
): Promise<void> {
// Substitui as linhas compiladas anteriores desse contexto — nunca
// acumula lixo de compilações antigas (mesmo padrão de "editar" já
// usado em reset-password/reveal-password: nunca reexpor/reaproveitar
// o estado velho, sempre um recorte limpo do estado atual).
await withTenantContext(prisma, tenantId, (tx) =>
tx.dialplanExtension.updateMany({
where: { tenantId, context: menu.context, deletedAt: null },
data: { deletedAt: new Date(), enabled: false },
}),
);
const compiled = buildIvrDialplanExtensions(menu, options);
await withTenantContext(prisma, tenantId, async (tx) => {
for (const ext of compiled) {
await tx.dialplanExtension.create({
data: {
tenantId,
context: menu.context,
name: ext.name,
conditionField: ext.conditionField,
conditionExpr: ext.conditionExpr,
actions: ext.actions as unknown as Prisma.InputJsonValue,
continueOnFalse: ext.continueOnFalse,
order: ext.order,
},
});
}
});
const xml = buildDialplanXml(menu.context, compiled);
const validation = XMLValidator.validate(xml);
if (validation !== true) {
throw new BadRequestException(`XML gerado invalido: ${validation.err.msg}`);
}
const last = await withTenantContext(prisma, tenantId, (tx) =>
tx.dialplanVersion.findFirst({ where: { tenantId, context: menu.context }, orderBy: { version: "desc" } }),
);
const nextVersion = (last?.version ?? 0) + 1;
await withTenantContext(prisma, tenantId, async (tx) => {
await tx.dialplanVersion.updateMany({
where: { tenantId, context: menu.context, status: "ACTIVE" },
data: { status: "SUPERSEDED" },
});
await tx.dialplanVersion.create({
data: {
tenantId,
context: menu.context,
version: nextVersion,
generatedXml: xml,
status: "ACTIVE",
createdByUserId: userId,
activatedAt: new Date(),
},
});
});
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("ivr-menus")
export class IvrMenusController {
@RequirePermission("ivr.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateIvrMenuDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
let menu;
try {
menu = await withTenantContext(prisma, tenantId, async (tx) => {
const created = await tx.ivrMenu.create({
data: { tenantId, name: dto.name, context: dto.context, greeting: dto.greeting },
});
await tx.ivrMenuOption.createMany({
data: dto.options.map((opt) => ({
tenantId,
ivrMenuId: created.id,
digit: opt.digit,
destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default",
label: opt.label,
})),
});
return created;
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
throw new ConflictException("Já existe um menu de IVR com esse contexto neste tenant");
}
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 recordAuditEvent(prisma, {
action: "IVR_MENU_CREATE",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: menu.id,
after: { name: menu.name, context: menu.context, optionCount: dto.options.length },
});
return this.get(user, menu.id);
}
@RequirePermission("ivr.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
return withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.findMany({
where: { deletedAt: null },
include: { options: { orderBy: { digit: "asc" } } },
orderBy: { name: "asc" },
}),
);
}
@RequirePermission("ivr.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.findFirst({
where: { id, deletedAt: null },
include: { options: { orderBy: { digit: "asc" } } },
}),
);
if (!menu) throw new NotFoundException();
return menu;
}
@RequirePermission("ivr.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateIvrMenuDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const existing = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
);
if (!existing) throw new NotFoundException();
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
const updated = await tx.ivrMenu.update({
where: { id },
data: { ...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.greeting !== undefined ? { greeting: dto.greeting } : {}) },
});
if (dto.options) {
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
await tx.ivrMenuOption.createMany({
data: dto.options.map((opt) => ({
tenantId,
ivrMenuId: id,
digit: opt.digit,
destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default",
label: opt.label,
})),
});
}
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 recordAuditEvent(prisma, {
action: "IVR_MENU_UPDATE",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
after: { name: menu.name, greeting: menu.greeting, optionCount: options.length },
});
return this.get(user, id);
}
@RequirePermission("ivr.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.findFirst({ where: { id, deletedAt: null } }));
if (!menu) throw new NotFoundException();
await withTenantContext(prisma, tenantId, async (tx) => {
await tx.ivrMenu.update({ where: { id }, data: { deletedAt: new Date(), enabled: false } });
await tx.dialplanExtension.updateMany({
where: { tenantId, context: menu.context, deletedAt: null },
data: { deletedAt: new Date(), enabled: false },
});
});
await recordAuditEvent(prisma, {
action: "IVR_MENU_DELETE",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
});
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { IvrMenusController } from "./ivr-menus.controller";
@Module({
controllers: [IvrMenusController],
})
export class IvrModule {}

View File

@@ -0,0 +1,55 @@
"use server";
import { revalidatePath } from "next/cache";
import { requireSession } from "@/lib/session";
import { apiFetch, ApiError } from "@/lib/api";
import type { IvrMenu } from "@/lib/callcenter-types";
function extractErrorMessage(err: unknown): string {
if (err instanceof ApiError) {
try {
const parsed = JSON.parse(err.message);
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
if (typeof parsed.message === "string") return parsed.message;
} catch {
// corpo não era JSON
}
return err.message || "Falha inesperada na API.";
}
return "Falha inesperada. Tente novamente.";
}
export interface IvrMenuOptionInput {
digit: string;
destinationNumber: string;
label?: string;
}
export interface CreateIvrMenuInput {
name: string;
context: string;
greeting?: string;
options: IvrMenuOptionInput[];
}
export async function createIvrMenu(input: CreateIvrMenuInput): Promise<{ ok: true; menu: IvrMenu } | { ok: false; error: string }> {
const session = await requireSession();
try {
const menu = await apiFetch<IvrMenu>("/ivr-menus", session.accessToken, { method: "POST", body: JSON.stringify(input) });
revalidatePath("/app/telefonia/ivr");
return { ok: true, menu };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}
export async function deleteIvrMenu(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
const session = await requireSession();
try {
await apiFetch<void>(`/ivr-menus/${id}`, session.accessToken, { method: "DELETE" });
revalidatePath("/app/telefonia/ivr");
return { ok: true };
} catch (err) {
return { ok: false, error: extractErrorMessage(err) };
}
}

View File

@@ -0,0 +1,314 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ListTree, Plus, Trash2, X } from "lucide-react";
import { Panel, PanelHeader } from "@/components/ui/panel";
import { Button } from "@/components/ui/button";
import { Input, Select, FieldLabel } from "@/components/ui/input";
import { Pill } from "@/components/ui/pill";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { ALLOWED_IVR_DIGITS, IVR_ENTRY_DESTINATION, type IvrMenu } from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types";
import { createIvrMenu, deleteIvrMenu, type IvrMenuOptionInput } from "./actions";
function slugifyContext(name: string): string {
return (
"ivr-" +
name
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
);
}
export function IvrView({ menus, extensions }: { menus: IvrMenu[]; extensions: Extension[] }) {
const [showForm, setShowForm] = useState(false);
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-lg font-semibold text-foreground">IVR</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Menus de atendimento automático a pessoa liga, ouve um prompt e escolhe um dígito, que cai num ramal
deste tenant. Pra receber ligações por esse menu, aponte uma rota de entrada (Telefonia &gt; Rotas de
Entrada) pro contexto e destino mostrados abaixo de cada menu.
</p>
</div>
<Button type="button" onClick={() => setShowForm((s) => !s)}>
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
{showForm ? "Cancelar" : "Novo menu"}
</Button>
</div>
{showForm && <NewIvrMenuForm extensions={extensions} existingContexts={menus.map((m) => m.context)} onDone={() => setShowForm(false)} />}
<Panel>
<PanelHeader title="Menus cadastrados" description={`${menus.length} menu(s) neste tenant`} />
{menus.length === 0 ? (
<EmptyState title="Nenhum menu de IVR cadastrado ainda" description="Crie o primeiro menu deste tenant." />
) : (
<ul className="divide-y divide-border">
{menus.map((menu) => (
<li key={menu.id} className="space-y-3 px-5 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="flex items-center gap-2 font-medium text-foreground">
<ListTree className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
{menu.name}
<Pill tone={menu.enabled ? "accent" : "neutral"}>{menu.enabled ? "Ativo" : "Desativado"}</Pill>
</span>
<DeleteIvrMenuButton menuId={menu.id} menuName={menu.name} />
</div>
<p className="font-mono text-xs text-muted-foreground">
Rota de entrada: contexto <span className="text-foreground">{menu.context}</span> · destino{" "}
<span className="text-foreground">{IVR_ENTRY_DESTINATION}</span>
</p>
<Table>
<THead>
<TR>
<TH>Dígito</TH>
<TH>Destino</TH>
<TH>Descrição</TH>
</TR>
</THead>
<TBody>
{menu.options.map((opt) => (
<TR key={opt.id}>
<TD className="font-mono font-medium text-foreground">{opt.digit}</TD>
<TD className="font-mono text-muted-foreground">{opt.destinationNumber}</TD>
<TD className="text-muted-foreground">{opt.label ?? "—"}</TD>
</TR>
))}
</TBody>
</Table>
</li>
))}
</ul>
)}
</Panel>
</div>
);
}
interface OptionRow {
digit: string;
destinationNumber: string;
label: string;
}
function NewIvrMenuForm({
extensions,
existingContexts,
onDone,
}: {
extensions: Extension[];
existingContexts: string[];
onDone: () => void;
}) {
const [name, setName] = useState("");
const [context, setContext] = useState("");
const [contextTouched, setContextTouched] = useState(false);
const [options, setOptions] = useState<OptionRow[]>([{ digit: "1", destinationNumber: "", label: "" }]);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const effectiveContext = contextTouched ? context : slugifyContext(name);
const usedDigits = useMemo(() => new Set(options.map((o) => o.digit)), [options]);
function updateOption(index: number, patch: Partial<OptionRow>) {
setOptions((prev) => prev.map((o, i) => (i === index ? { ...o, ...patch } : o)));
}
function addOption() {
const nextDigit = ALLOWED_IVR_DIGITS.find((d) => !usedDigits.has(d)) ?? "1";
setOptions((prev) => [...prev, { digit: nextDigit, destinationNumber: "", label: "" }]);
}
function removeOption(index: number) {
setOptions((prev) => prev.filter((_, i) => i !== index));
}
function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!name.trim() || !effectiveContext.trim()) {
setError("Nome é obrigatório.");
return;
}
if (existingContexts.includes(effectiveContext)) {
setError(`Já existe um menu com o contexto "${effectiveContext}" — escolha outro nome.`);
return;
}
if (options.length === 0 || options.some((o) => !o.destinationNumber.trim())) {
setError("Toda opção precisa de um dígito e um ramal de destino.");
return;
}
const digits = options.map((o) => o.digit);
if (new Set(digits).size !== digits.length) {
setError("Não pode repetir o mesmo dígito em duas opções.");
return;
}
const payloadOptions: IvrMenuOptionInput[] = options.map((o) => ({
digit: o.digit,
destinationNumber: o.destinationNumber.trim(),
label: o.label.trim() || undefined,
}));
startTransition(async () => {
const result = await createIvrMenu({ name: name.trim(), context: effectiveContext, options: payloadOptions });
if (!result.ok) {
setError(result.error);
return;
}
onDone();
});
}
return (
<Panel className="p-5">
<form onSubmit={onSubmit} noValidate className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<FieldLabel htmlFor="ivr-name">Nome do menu</FieldLabel>
<Input id="ivr-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Ex.: Vendas" disabled={pending} />
</div>
<div>
<FieldLabel htmlFor="ivr-context">Contexto (gerado do nome, editável)</FieldLabel>
<Input
id="ivr-context"
value={effectiveContext}
onChange={(e) => {
setContextTouched(true);
setContext(e.target.value);
}}
placeholder="ivr-vendas"
disabled={pending}
/>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<FieldLabel htmlFor="ivr-opt-0">Opções do menu</FieldLabel>
<Button type="button" variant="ghost" size="sm" onClick={addOption} disabled={pending || options.length >= 12}>
<Plus className="h-3.5 w-3.5" aria-hidden /> Adicionar opção
</Button>
</div>
{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>
<FieldLabel htmlFor={`ivr-opt-${i}-digit`}>Dígito</FieldLabel>
<Select
id={`ivr-opt-${i}-digit`}
value={opt.digit}
onChange={(e) => updateOption(i, { digit: e.target.value })}
disabled={pending}
>
{ALLOWED_IVR_DIGITS.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor={`ivr-opt-${i}-dest`}>Ramal de destino</FieldLabel>
<Select
id={`ivr-opt-${i}-dest`}
value={opt.destinationNumber}
onChange={(e) => updateOption(i, { destinationNumber: e.target.value })}
disabled={pending}
>
<option value="">Selecione um ramal</option>
{extensions.map((ext) => (
<option key={ext.id} value={ext.number}>
{ext.number} {ext.name}
</option>
))}
</Select>
</div>
<div>
<FieldLabel htmlFor={`ivr-opt-${i}-label`}>Descrição (opcional)</FieldLabel>
<Input
id={`ivr-opt-${i}-label`}
value={opt.label}
onChange={(e) => updateOption(i, { label: e.target.value })}
placeholder="Ex.: Vendas"
disabled={pending}
/>
</div>
<div className="flex items-end justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeOption(i)}
disabled={pending || options.length <= 1}
aria-label={`Remover opção ${opt.digit}`}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</Button>
</div>
</div>
))}
</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 justify-end">
<Button type="submit" disabled={pending}>
{pending ? "Criando…" : "Criar menu"}
</Button>
</div>
</form>
</Panel>
);
}
function DeleteIvrMenuButton({ menuId, menuName }: { menuId: string; menuName: string }) {
const router = useRouter();
const [confirming, setConfirming] = useState(false);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function onClick() {
if (!confirming) {
setConfirming(true);
return;
}
setError(null);
startTransition(async () => {
const result = await deleteIvrMenu(menuId);
if (!result.ok) {
setError(result.error);
setConfirming(false);
return;
}
router.refresh();
});
}
return (
<div className="flex items-center gap-2">
{error && <span className="text-xs text-destructive">{error}</span>}
<Button
type="button"
variant={confirming ? "destructive" : "ghost"}
size="sm"
onClick={onClick}
disabled={pending}
aria-label={confirming ? `Confirmar remoção de ${menuName}` : `Remover ${menuName}`}
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
{confirming ? "Confirmar" : ""}
</Button>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { requireSession } from "@/lib/session";
import { apiFetch } from "@/lib/api";
import type { IvrMenu } from "@/lib/callcenter-types";
import type { Extension } from "@/lib/extension-types";
import { IvrView } from "./ivr-view";
export default async function IvrPage() {
const session = await requireSession();
const [menus, extensions] = await Promise.all([
apiFetch<IvrMenu[]>("/ivr-menus", session.accessToken),
apiFetch<Extension[]>("/extensions", session.accessToken),
]);
return <IvrView menus={menus} extensions={extensions} />;
}

View File

@@ -105,6 +105,12 @@ export const TENANT_NAV: NavSection[] = [
description: "Números (DID) recebidos por tronco — pra qual ramal/fila/IVR cada um cai", description: "Números (DID) recebidos por tronco — pra qual ramal/fila/IVR cada um cai",
permission: "inbound_routes.view", permission: "inbound_routes.view",
}, },
{
label: "IVR",
href: "/app/telefonia/ivr",
description: "Menus de atendimento automático — prompt + dígito escolhido leva a um ramal",
permission: "ivr.view",
},
{ {
label: "Dialplan", label: "Dialplan",
href: "/app/telefonia/dialplan", href: "/app/telefonia/dialplan",

View File

@@ -57,6 +57,30 @@ export interface InboundRoute {
createdAt: string; createdAt: string;
} }
export const ALLOWED_IVR_DIGITS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "*", "#"] as const;
/** destination_number fixo pra entrar num menu — toda InboundRoute que
* aponta pra um IvrMenu usa isso como destinationNumber (packages/telephony). */
export const IVR_ENTRY_DESTINATION = "ivr_entry";
export interface IvrMenuOption {
id: string;
digit: string;
destinationNumber: string;
destinationContext: string;
label: string | null;
}
export interface IvrMenu {
id: string;
name: string;
context: string;
greeting: string | null;
enabled: boolean;
createdAt: string;
options: IvrMenuOption[];
}
export interface PauseReason { export interface PauseReason {
id: string; id: string;
name: string; name: string;

View File

@@ -123,15 +123,43 @@ bidirecional confirmado); dígito `2` → resposta alternativa (tom
diferente + desliga) — confirma que a ramificação distingue de verdade, diferente + desliga) — confirma que a ramificação distingue de verdade,
não só "sempre cai na primeira opção". não só "sempre cai na primeira opção".
## Tela de autoria de IVR (PHASE 58)
`Telefonia > IVR` (`apps/frontend/.../telefonia/ivr`) — cria um `IvrMenu`
(nome + contexto derivado do nome) com opções (dígito → ramal + rótulo
opcional), sem precisar tocar no editor genérico de dialplan. Por baixo,
`IvrMenusController` (`POST/PATCH/DELETE /ivr-menus`) compila o menu +
opções em `buildIvrDialplanExtensions` (`packages/telephony/src/ivr-xml.ts`)
e já gera + ativa uma nova versão do dialplan do contexto — o mesmo fluxo
generate+activate manual, automático. `IVR_ENTRY_DESTINATION`
("ivr_entry") é o valor fixo que toda `InboundRoute` precisa usar como
`destinationNumber` pra entrar nesse menu (`destinationContext =
IvrMenu.context`).
`greeting` (o prompt tocado ao entrar) passa pela MESMA proteção
anti-RCE de `data` de dialplan (`IsSafeDialplanData`) — é texto livre do
tenant, nunca pode virar `${system(...)}`. Sem pipeline de upload/TTS de
áudio ainda: null usa um tom padrão; texto livre vira o argumento `file`
de `play_and_get_digits` (aceita qualquer caminho/URL que o FreeSWITCH
já resolva, incluindo `tone_stream://`).
Testado ponta a ponta criando um menu de verdade via API (não só
lendo XML manualmente escrito): softphone externo discou o DID, o menu
recém-criado atendeu, tocou o prompt, colheu o dígito com DTMF real
(`uuid_recv_dtmf`), e bridged com o ramal certo — confirmando que o
compilador produz XML funcionalmente idêntico ao testado manualmente na
PHASE 56.
## O que falta ## O que falta
- Tela de frontend pra autoria de IVR — hoje o menu é construído à mão - Sem pipeline de upload/TTS de prompt de áudio — hoje é texto livre
no editor genérico de dialplan (`Telefonia > Dialplan`, escolhendo um (tom padrão ou um caminho/URL que o FreeSWITCH já sabe tocar).
`context` novo), não tem uma UI dedicada de "menu com opções" ainda. - Menu de IVR não suporta sub-menus (uma opção levando a OUTRO IVR) nem
Backend já suporta tudo que uma UI assim precisaria gerar. destino "fila" — só ramal, dentro do contexto `default`.
- Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID → - Tela de frontend "Rotas de Entrada" cobre só CRUD simples (DID →
ramal); não tem seletor de "fila" ou "IVR" como destino ainda — hoje é ramal); não tem seletor dedicado de "IVR" como destino ainda (o
texto livre pro `destinationContext`/`destinationNumber`. operador digita o contexto/`ivr_entry` manualmente, mostrados na
própria tela de IVR pra copiar).
- Perda das proteções de toll-fraud do `public.xml` vanilla (unroll de - Perda das proteções de toll-fraud do `public.xml` vanilla (unroll de
loop de chamada, etc.) — não replicadas no contexto `inbound` novo. loop de chamada, etc.) — não replicadas no contexto `inbound` novo.
Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de

View File

@@ -25,6 +25,8 @@ const PERMISSIONS: Array<{ key: string; description: string }> = [
{ key: "trunks.manage", description: "Criar/editar troncos" }, { key: "trunks.manage", description: "Criar/editar troncos" },
{ key: "inbound_routes.view", description: "Ver rotas de entrada" }, { key: "inbound_routes.view", description: "Ver rotas de entrada" },
{ key: "inbound_routes.manage", description: "Criar/editar 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.view", description: "Ver agentes" },
{ key: "agents.manage", description: "Criar/editar agentes" }, { key: "agents.manage", description: "Criar/editar agentes" },
{ key: "queues.view", description: "Ver filas" }, { key: "queues.view", description: "Ver filas" },
@@ -62,6 +64,7 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
"extensions.view", "extensions.view",
"trunks.view", "trunks.view",
"inbound_routes.view", "inbound_routes.view",
"ivr.view",
"agents.view", "agents.view",
"agents.manage", "agents.manage",
"queues.view", "queues.view",

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[] extensions Extension[]
trunks Trunk[] trunks Trunk[]
inboundRoutes InboundRoute[] inboundRoutes InboundRoute[]
ivrMenus IvrMenu[]
ivrMenuOptions IvrMenuOption[]
dialplanExtensions DialplanExtension[] dialplanExtensions DialplanExtension[]
dialplanVersions DialplanVersion[] dialplanVersions DialplanVersion[]
queues Queue[] queues Queue[]
@@ -480,6 +482,64 @@ model InboundRoute {
@@map("inbound_routes") @@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 { enum DialplanVersionStatus {
DRAFT DRAFT
ACTIVE ACTIVE

View File

@@ -5,4 +5,5 @@ export * from "./directory-xml";
export * from "./gateway-xml"; export * from "./gateway-xml";
export * from "./dialplan-xml"; export * from "./dialplan-xml";
export * from "./default-dialplan"; export * from "./default-dialplan";
export * from "./ivr-xml";
export * from "./queue-xml"; export * from "./queue-xml";

View 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];
}