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

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