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 { const menus = await tx.ivrMenu.findMany({ where: { tenantId, deletedAt: null }, select: { context: true, options: { select: { destinationType: true, destinationContext: true } } }, }); const edges = new Map(); 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(); 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, tenantId: string, userId: string, menu: { context: string; greeting: string | null }, options: IvrMenuOptionInput[], ): Promise { // 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))); } }