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
484 lines
18 KiB
TypeScript
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)));
|
|
}
|
|
}
|