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:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user