import { readFile, writeFile } from 'node:fs/promises'; import { BadRequestException, Inject, Injectable, NotFoundException, } from '@nestjs/common'; import { DialplanVersionStatus } from '@b2bcall/database'; import type { TelephonyProvider } from '@b2bcall/telephony'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import type { RequestContext } from '../auth/auth.service'; import { TELEPHONY_PROVIDER } from '../telephony/telephony.module'; import { CreateDialplanEntryDto } from './dto/create-dialplan-entry.dto'; import { UpdateDialplanEntryDto } from './dto/update-dialplan-entry.dto'; import { generateDialplanConfig } from './dialplan-generator'; import { generateOutboundRoutesConfig, OUTBOUND_ROUTES_CONTEXT, } from '../outbound-routes/outbound-route-generator'; const DIALPLAN_FILE_PATH = '/etc/asterisk-generated/b2bcall-dialplan.conf'; // Padrões de erro que o Asterisk imprime quando um contexto/config não // existe ou falhou ao carregar — usados para detectar falha de aplicação // já que a API não tem acesso a um "dry-run" real de parsing do Asterisk. const ERROR_MARKERS = [ /There is no existence of/i, /failed to load/i, /Parse error/i, ]; @Injectable() export class DialplanService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, @Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider, ) {} listEntries(context?: string) { return this.prisma.dialplanEntry.findMany({ where: context ? { context } : undefined, orderBy: [ { context: 'asc' }, { order: 'asc' }, { exten: 'asc' }, { priority: 'asc' }, ], }); } async createEntry( dto: CreateDialplanEntryDto, actor: { id: string }, ctx: RequestContext, ) { const entry = await this.prisma.dialplanEntry.create({ data: { context: dto.context, exten: dto.exten, priority: dto.priority, application: dto.application, argument: dto.argument, enabled: dto.enabled ?? true, order: dto.order ?? 0, }, }); await this.audit.log({ userId: actor.id, action: 'dialplan_entry_created', entityType: 'dialplan_entry', entityId: entry.id, after: { ...dto }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); return entry; } async updateEntry( id: string, dto: UpdateDialplanEntryDto, actor: { id: string }, ctx: RequestContext, ) { const before = await this.prisma.dialplanEntry.findUnique({ where: { id }, }); if (!before) throw new NotFoundException('Entrada de dialplan não encontrada.'); const entry = await this.prisma.dialplanEntry.update({ where: { id }, data: dto, }); await this.audit.log({ userId: actor.id, action: 'dialplan_entry_updated', entityType: 'dialplan_entry', entityId: id, before, after: { ...dto }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); return entry; } async deleteEntry(id: string, actor: { id: string }, ctx: RequestContext) { const entry = await this.prisma.dialplanEntry.findUnique({ where: { id } }); if (!entry) throw new NotFoundException('Entrada de dialplan não encontrada.'); await this.prisma.dialplanEntry.delete({ where: { id } }); await this.audit.log({ userId: actor.id, action: 'dialplan_entry_deleted', entityType: 'dialplan_entry', entityId: id, before: entry, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); } listVersions() { return this.prisma.dialplanVersion.findMany({ orderBy: { createdAt: 'desc' }, take: 50, }); } private async writeAndReload(config: string): Promise { await writeFile(DIALPLAN_FILE_PATH, config, 'utf8'); return this.telephony.runCommand('dialplan reload'); } private async verify(entries: { context: string }[]): Promise { const contexts = [...new Set(entries.map((e) => e.context))]; for (const context of contexts) { const output = await this.telephony.runCommand( `dialplan show ${context}`, ); const errorMarker = ERROR_MARKERS.find((re) => re.test(output)); if (errorMarker) return `Contexto '${context}': ${output.slice(0, 300)}`; } return null; } /** * Gera a configuração a partir das entradas ativas, salva backup da * versão anterior, escreve o novo arquivo, recarrega e valida. Se algo * falhar, restaura automaticamente a versão anterior e recarrega de novo * (agente.md seção 21: "caso inválido, não ativar" + "permitir rollback"). */ async publish(actor: { id: string }, ctx: RequestContext) { const entries = await this.prisma.dialplanEntry.findMany({ where: { enabled: true }, }); const outboundRoutes = await this.prisma.outboundRoute.findMany({ where: { enabled: true }, include: { trunk: { select: { name: true } } }, }); const newConfig = generateDialplanConfig(entries) + '\n' + generateOutboundRoutesConfig(outboundRoutes); const verifyContexts = [ ...entries.map((e) => ({ context: e.context })), { context: OUTBOUND_ROUTES_CONTEXT }, { context: 'b2bcall-agents' }, ]; const previous = await this.prisma.dialplanVersion.findFirst({ where: { status: DialplanVersionStatus.APPLIED }, orderBy: { createdAt: 'desc' }, }); const previousConfig = previous?.generatedConfig ?? (await this.readCurrentFileSafely()); let reloadResult: string; try { reloadResult = await this.writeAndReload(newConfig); } catch (err) { throw new BadRequestException( `Falha ao recarregar o dialplan: ${(err as Error).message}`, ); } const failureReason = await this.verify(verifyContexts); if (failureReason) { // Rollback automático — nunca deixa uma configuração inválida ativa. await this.writeAndReload(previousConfig); const failedVersion = await this.prisma.dialplanVersion.create({ data: { generatedConfig: newConfig, status: DialplanVersionStatus.FAILED, reloadResult: failureReason, createdById: actor.id, }, }); await this.audit.log({ userId: actor.id, action: 'dialplan_publish_failed_rolled_back', entityType: 'dialplan_version', entityId: failedVersion.id, after: { failureReason }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); throw new BadRequestException( `Publicação falhou na validação e foi revertida automaticamente: ${failureReason}`, ); } const version = await this.prisma.dialplanVersion.create({ data: { generatedConfig: newConfig, status: DialplanVersionStatus.APPLIED, reloadResult, createdById: actor.id, }, }); await this.audit.log({ userId: actor.id, action: 'dialplan_published', entityType: 'dialplan_version', entityId: version.id, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); return version; } async rollback( versionId: string, actor: { id: string }, ctx: RequestContext, ) { const target = await this.prisma.dialplanVersion.findUnique({ where: { id: versionId }, }); if (!target) throw new NotFoundException('Versão de dialplan não encontrada.'); const reloadResult = await this.writeAndReload(target.generatedConfig); const version = await this.prisma.dialplanVersion.create({ data: { generatedConfig: target.generatedConfig, status: DialplanVersionStatus.ROLLED_BACK, reloadResult, createdById: actor.id, }, }); await this.audit.log({ userId: actor.id, action: 'dialplan_rolled_back', entityType: 'dialplan_version', entityId: version.id, before: { rolledBackFrom: versionId }, ipAddress: ctx.ip, userAgent: ctx.userAgent, }); return version; } private async readCurrentFileSafely(): Promise { try { return await readFile(DIALPLAN_FILE_PATH, 'utf8'); } catch { return '; vazio\n'; } } }