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.
283 lines
8.2 KiB
TypeScript
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';
|
|
}
|
|
}
|
|
}
|