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

@@ -3,10 +3,18 @@ import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsNumber, IsOptional, IsStri
import { ALLOWED_IVR_DIGITS } from "@b2bcall/telephony";
import { IsSafeDialplanData } from "../../dialplan/dto/safe-dialplan-data.validator";
const IVR_OPTION_DESTINATION_TYPES = ["EXTENSION", "QUEUE", "IVR"] as const;
export class IvrMenuOptionDto {
@IsIn(ALLOWED_IVR_DIGITS)
digit!: string;
// PHASE 67 — decide como destinationNumber/destinationContext são
// interpretados (ver comentário em IvrMenuOption no schema).
@IsOptional()
@IsIn(IVR_OPTION_DESTINATION_TYPES)
destinationType?: (typeof IVR_OPTION_DESTINATION_TYPES)[number];
@IsString()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
destinationNumber!: string;

View File

@@ -49,6 +49,53 @@ function promptHostPath(tenantId: string, menuId: string): string {
return join(IVR_PROMPTS_HOST_ROOT, tenantId, `${menuId}.wav`);
}
/**
* PHASE 67 — destino "Outro IVR" cria um grafo entre menus (algo que
* InboundRoute->IVR nunca tinha, já que uma rota de entrada não é ela
* mesma um menu). Sem essa checagem, dois menus apontando um pro outro
* (ou um menu apontando pra si mesmo) compilam sem erro nenhum e só
* travam a experiência do CHAMADOR depois (fica indo de um prompt pro
* outro pra sempre) — pega isso antes de compilar, não depois de alguém
* reclamar. Monta o grafo com TODOS os menus do tenant (não só os que já
* existiam antes desta operação), substituindo as arestas do menu sendo
* salvo pelas opções novas — cobre tanto create quanto update.
*/
async function assertNoIvrCycle(
tx: Prisma.TransactionClient,
tenantId: string,
menuContext: string,
newOptions: IvrMenuOptionInput[],
): Promise<void> {
const menus = await tx.ivrMenu.findMany({
where: { tenantId, deletedAt: null },
select: { context: true, options: { select: { destinationType: true, destinationContext: true } } },
});
const edges = new Map<string, string[]>();
for (const m of menus) {
edges.set(
m.context,
m.options.filter((o) => o.destinationType === "IVR").map((o) => o.destinationContext),
);
}
edges.set(
menuContext,
newOptions.filter((o) => o.destinationType === "IVR").map((o) => o.destinationContext),
);
const visited = new Set<string>();
const stack = [...(edges.get(menuContext) ?? [])];
while (stack.length > 0) {
const next = stack.pop()!;
if (next === menuContext) {
throw new ConflictException(`Esse destino criaria um ciclo entre menus de IVR (via "${next}")`);
}
if (visited.has(next)) continue;
visited.add(next);
stack.push(...(edges.get(next) ?? []));
}
}
/**
* 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`
@@ -133,22 +180,31 @@ export class IvrMenusController {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const createOptions: IvrMenuOptionInput[] = dto.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType ?? "EXTENSION",
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext ?? "default",
}));
let menu;
try {
menu = await withTenantContext(prisma, tenantId, async (tx) => {
await assertNoIvrCycle(tx, tenantId, dto.context, createOptions);
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) => ({
data: createOptions.map((opt, i) => ({
tenantId,
ivrMenuId: created.id,
digit: opt.digit,
destinationType: opt.destinationType,
destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default",
label: opt.label,
positionX: opt.positionX,
positionY: opt.positionY,
destinationContext: opt.destinationContext,
label: dto.options[i].label,
positionX: dto.options[i].positionX,
positionY: dto.options[i].positionY,
})),
});
return created;
@@ -160,11 +216,6 @@ export class IvrMenusController {
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, {
@@ -219,7 +270,24 @@ export class IvrMenusController {
);
if (!existing) throw new NotFoundException();
const options: IvrMenuOptionInput[] = dto.options
? dto.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType ?? "EXTENSION",
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext ?? "default",
}))
: existing.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));
const menu = await withTenantContext(prisma, tenantId, async (tx) => {
if (dto.options) {
await assertNoIvrCycle(tx, tenantId, existing.context, options);
}
const updated = await tx.ivrMenu.update({
where: { id },
data: {
@@ -232,12 +300,13 @@ export class IvrMenusController {
if (dto.options) {
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
await tx.ivrMenuOption.createMany({
data: dto.options.map((opt) => ({
data: dto.options.map((opt, i) => ({
tenantId,
ivrMenuId: id,
digit: opt.digit,
destinationType: options[i].destinationType,
destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext ?? "default",
destinationContext: options[i].destinationContext,
label: opt.label,
positionX: opt.positionX,
positionY: opt.positionY,
@@ -247,10 +316,6 @@ export class IvrMenusController {
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, {
@@ -342,6 +407,7 @@ export class IvrMenusController {
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));
@@ -380,6 +446,7 @@ export class IvrMenusController {
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));