import { BadRequestException, Controller, Get, NotFoundException, Param, Post, Query, UseGuards, } from "@nestjs/common"; import { XMLValidator } from "fast-xml-parser"; import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth"; import { buildDialplanXml, type AllowedDialplanApplication } 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"; @UseGuards(JwtAuthGuard, PermissionGuard) @Controller("dialplan/versions") export class DialplanVersionsController { /** * "Gerar" + "validar" (agente.md secao 44): monta o XML a partir das * linhas atuais de dialplan_extensions pro context, valida * bem-formação, salva como nova versão DRAFT (não afeta chamadas até * ser ativada). */ @RequirePermission("freeswitch.configure") @Post("generate") async generate(@CurrentUser() user: AccessTokenClaims, @Query("context") context = "default") { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const extensions = await withTenantContext(prisma, tenantId, (tx) => tx.dialplanExtension.findMany({ where: { context, enabled: true, deletedAt: null }, orderBy: { order: "asc" }, }), ); const xml = buildDialplanXml( context, extensions.map((e) => ({ name: e.name, conditionField: e.conditionField, conditionExpr: e.conditionExpr, actions: e.actions as unknown as { application: AllowedDialplanApplication; data?: string }[], antiActions: e.antiActions as unknown as | { application: AllowedDialplanApplication; data?: string }[] | undefined, continueOnFalse: e.continueOnFalse, order: e.order, })), ); const validation = XMLValidator.validate(xml); if (validation !== true) { // Bug nosso (o XML é gerado por template escapado, nunca deveria // acontecer) — melhor falhar alto do que salvar XML quebrado. throw new BadRequestException(`XML gerado invalido: ${validation.err.msg}`); } const last = await withTenantContext(prisma, tenantId, (tx) => tx.dialplanVersion.findFirst({ where: { tenantId, context }, orderBy: { version: "desc" }, }), ); const nextVersion = (last?.version ?? 0) + 1; const version = await withTenantContext(prisma, tenantId, (tx) => tx.dialplanVersion.create({ data: { tenantId, context, version: nextVersion, generatedXml: xml, status: "DRAFT", createdByUserId: user.sub, }, }), ); await recordAuditEvent(prisma, { action: "DIALPLAN_VERSION_GENERATE", tenantId, userId: user.sub, entityType: "dialplan_version", entityId: version.id, after: { context, version: nextVersion, extensionCount: extensions.length }, }); return version; } @RequirePermission("freeswitch.view") @Get() async list(@CurrentUser() user: AccessTokenClaims, @Query("context") context?: string) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; return withTenantContext(prisma, tenantId, (tx) => tx.dialplanVersion.findMany({ where: context ? { context } : {}, orderBy: [{ context: "asc" }, { version: "desc" }], select: { id: true, context: true, version: true, status: true, createdAt: true, activatedAt: true, createdByUserId: true, }, }), ); } /** * "Ativar" (secao 44). Reativar uma versão antiga É o rollback — não * existe endpoint separado. Como o dialplan é resolvido por chamada via * mod_xml_curl (não é config estática lida uma vez no boot), não precisa * de reloadxml/rescan: a próxima chamada já enxerga a versão ativa. */ @RequirePermission("freeswitch.configure") @Post(":id/activate") async activate(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const target = await withTenantContext(prisma, tenantId, (tx) => tx.dialplanVersion.findFirst({ where: { id, tenantId } }), ); if (!target) { throw new NotFoundException(); } // Já roda dentro da transação de withTenantContext — as duas operações // são atômicas sem precisar de um $transaction aninhado. await withTenantContext(prisma, tenantId, async (tx) => { await tx.dialplanVersion.updateMany({ where: { tenantId, context: target.context, status: "ACTIVE" }, data: { status: "SUPERSEDED" }, }); await tx.dialplanVersion.update({ where: { id: target.id }, data: { status: "ACTIVE", activatedAt: new Date() }, }); }); await recordAuditEvent(prisma, { action: "DIALPLAN_VERSION_ACTIVATE", tenantId, userId: user.sub, entityType: "dialplan_version", entityId: target.id, after: { context: target.context, version: target.version }, }); return withTenantContext(prisma, tenantId, (tx) => tx.dialplanVersion.findUniqueOrThrow({ where: { id: target.id } }), ); } }