Files
b2bcall/apps/api/src/dialplan/dialplan.service.ts
B2BCall Bootstrap 9857904c6d feat: add outbound routes (Issabel-style dial pattern masking)
Nova aba 'Dialplan -> Rotas de Saída': abstração amigável sobre o dialplan
bruto, no mesmo espírito das Outbound Routes do Issabel/FreePBX. O usuário
informa (prepend) + prefix | match pattern (sintaxe de padrão do Asterisk:
X/Z/N/faixas/coringas, sem o '_' inicial) e escolhe o tronco, sem escrever
exten=>/Dial() à mão.

- packages/database: model OutboundRoute + migration
- apps/api/src/outbound-routes: gerador determinístico (contexto
  b2bcall-outbound-routes, incluído em b2bcall-agents — contexto padrão de
  Extension.context), service/controller CRUD reaproveitando as permissões
  dialplans.*, 6 testes unitários
- apps/api/src/dialplan/dialplan.service.ts: publish() agora gera e
  verifica também o contexto das rotas, no mesmo pipeline de
  versionamento/rollback automático das entradas de dialplan brutas
- apps/frontend: aba com formulário (prepend/prefix/padrão/tronco) e
  preview ao vivo da máscara resultante

O prefixo é sempre removido do número antes de discar e substituído pelo
prepend — o tronco nunca vê o prefixo digitado pelo agente, só o número já
mascarado. Não usado pelo discador preditivo (campanhas já sabem o tronco
via Campaign.trunkId diretamente).

Build/lint/testes (api+frontend) verificados; teste end-to-end contra o
Asterisk real ficou pendente porque a senha do super_admin foi trocada
durante a sessão (acesso legítimo do usuário) — validação via UI delegada
ao usuário.
2026-08-27 20:13:21 -03:00

283 lines
8.2 KiB
TypeScript

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<string> {
await writeFile(DIALPLAN_FILE_PATH, config, 'utf8');
return this.telephony.runCommand('dialplan reload');
}
private async verify(entries: { context: string }[]): Promise<string | null> {
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<string> {
try {
return await readFile(DIALPLAN_FILE_PATH, 'utf8');
} catch {
return '; vazio\n';
}
}
}