Files
B2BCall-dialer/apps/api/src/ivr/ivr-menus.controller.ts
Matheus 432cd55adb 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
2026-08-31 09:41:22 -03:00

484 lines
18 KiB
TypeScript

import {
BadRequestException,
Body,
ConflictException,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
Req,
Res,
UseGuards,
} from "@nestjs/common";
import { createReadStream } from "node:fs";
import { mkdir, unlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyReply, FastifyRequest } from "fastify";
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";
// Path do HOST (apps/api roda fora do Docker) — o mesmo diretório
// aparece como /ivr-prompts dentro do container freeswitch (ver
// docker-compose.yml). `IvrMenu.greeting` grava o path como o
// FreeSWITCH enxerga (container), nunca o path do host.
const IVR_PROMPTS_HOST_ROOT = process.env.IVR_PROMPTS_HOST_ROOT ?? "/opt/b2bcall/data/ivr-prompts";
const IVR_PROMPTS_CONTAINER_ROOT = "/ivr-prompts";
function isValidWavHeader(buf: Buffer): boolean {
// RIFF....WAVE — cabeçalho mínimo, suficiente pra rejeitar qualquer
// coisa que não seja WAV antes de gravar no disco compartilhado com o
// FreeSWITCH (mod_sndfile está carregado e resample sozinho qualquer
// sample rate/canais válidos; não há mod_shout nesta implantação, então
// MP3 nunca funcionaria — melhor rejeitar cedo com mensagem clara).
return buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WAVE";
}
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`
* 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!;
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: createOptions.map((opt, i) => ({
tenantId,
ivrMenuId: created.id,
digit: opt.digit,
destinationType: opt.destinationType,
destinationNumber: opt.destinationNumber,
destinationContext: opt.destinationContext,
label: dto.options[i].label,
positionX: dto.options[i].positionX,
positionY: dto.options[i].positionY,
})),
});
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;
}
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 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: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.greeting !== undefined ? { greeting: dto.greeting } : {}),
...(dto.entryPositionX !== undefined ? { entryPositionX: dto.entryPositionX } : {}),
...(dto.entryPositionY !== undefined ? { entryPositionY: dto.entryPositionY } : {}),
},
});
if (dto.options) {
await tx.ivrMenuOption.deleteMany({ where: { ivrMenuId: id } });
await tx.ivrMenuOption.createMany({
data: dto.options.map((opt, i) => ({
tenantId,
ivrMenuId: id,
digit: opt.digit,
destinationType: options[i].destinationType,
destinationNumber: opt.destinationNumber,
destinationContext: options[i].destinationContext,
label: opt.label,
positionX: opt.positionX,
positionY: opt.positionY,
})),
});
}
return updated;
});
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 },
});
});
if (menu.greeting?.startsWith(IVR_PROMPTS_CONTAINER_ROOT)) {
try {
await unlink(promptHostPath(tenantId, id));
} catch {
// arquivo já não existia — sem problema, o menu já foi apagado.
}
}
await recordAuditEvent(prisma, {
action: "IVR_MENU_DELETE",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
});
}
/**
* Upload do prompt de áudio (PHASE 59, docs/INBOUND_ROUTES.md) —
* único endpoint desta API que recebe um arquivo binário. Grava no
* disco compartilhado com o FreeSWITCH (nunca no object storage de
* gravações — aquele é lido por humanos depois da chamada via proxy
* autenticado; este precisa ser lido pelo PRÓPRIO FreeSWITCH ao vivo
* durante `play_and_get_digits`, então tem que ser um arquivo local
* de verdade, não uma URL de rede) e recompila o dialplan do menu com
* o novo `greeting` apontando pro path que o FreeSWITCH enxerga.
*/
@RequirePermission("ivr.manage")
@Post(":id/prompt")
async uploadPrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Req() request: FastifyRequest) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.findFirst({ where: { id, deletedAt: null }, include: { options: true } }),
);
if (!menu) throw new NotFoundException();
const data = await request.file();
if (!data) throw new BadRequestException("Nenhum arquivo enviado");
const buffer = await data.toBuffer();
if (!isValidWavHeader(buffer)) {
throw new BadRequestException(
"Arquivo não é um WAV válido — só WAV é aceito (esta implantação do FreeSWITCH não tem suporte a MP3)",
);
}
const hostPath = promptHostPath(tenantId, id);
await mkdir(join(IVR_PROMPTS_HOST_ROOT, tenantId), { recursive: true });
await writeFile(hostPath, buffer);
const containerPath = `${IVR_PROMPTS_CONTAINER_ROOT}/${tenantId}/${id}.wav`;
const updated = await withTenantContext(prisma, tenantId, (tx) =>
tx.ivrMenu.update({ where: { id }, data: { greeting: containerPath } }),
);
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, updated, options);
await recordAuditEvent(prisma, {
action: "IVR_MENU_PROMPT_UPLOAD",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
after: { filename: data.filename, sizeBytes: buffer.length },
});
return this.get(user, id);
}
@RequirePermission("ivr.manage")
@Delete(":id/prompt")
async removePrompt(@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: true } }),
);
if (!menu) throw new NotFoundException();
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.update({ where: { id }, data: { greeting: null } }));
try {
await unlink(promptHostPath(tenantId, id));
} catch {
// arquivo já não existia — nada a fazer, greeting já voltou a null.
}
const options: IvrMenuOptionInput[] = menu.options.map((o) => ({
digit: o.digit,
destinationType: o.destinationType,
destinationNumber: o.destinationNumber,
destinationContext: o.destinationContext,
}));
await compileAndActivateIvrDialplan(prisma, tenantId, user.sub, updated, options);
await recordAuditEvent(prisma, {
action: "IVR_MENU_PROMPT_DELETE",
tenantId,
userId: user.sub,
entityType: "ivr_menu",
entityId: id,
});
return this.get(user, id);
}
/** Preview autenticado do prompt — mesmo princípio do player de
* gravações (nunca uma URL direta pro storage/disco). */
@RequirePermission("ivr.view")
@Get(":id/prompt")
async downloadPrompt(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Res() reply: FastifyReply) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const menu = await withTenantContext(prisma, tenantId, (tx) => tx.ivrMenu.findFirst({ where: { id, deletedAt: null } }));
if (!menu || !menu.greeting?.startsWith(IVR_PROMPTS_CONTAINER_ROOT)) {
throw new NotFoundException();
}
reply.header("Content-Type", "audio/wav");
reply.header("Content-Disposition", "inline");
reply.send(createReadStream(promptHostPath(tenantId, id)));
}
}