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.
This commit is contained in:
@@ -18,6 +18,7 @@ import { TrunksModule } from './trunks/trunks.module';
|
||||
import { ExtensionsModule } from './extensions/extensions.module';
|
||||
import { MonitoringModule } from './monitoring/monitoring.module';
|
||||
import { DialplanModule } from './dialplan/dialplan.module';
|
||||
import { OutboundRoutesModule } from './outbound-routes/outbound-routes.module';
|
||||
import { AsteriskAdminModule } from './asterisk-admin/asterisk-admin.module';
|
||||
import { PauseReasonsModule } from './pause-reasons/pause-reasons.module';
|
||||
import { QueuesModule } from './queues/queues.module';
|
||||
@@ -78,6 +79,7 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
||||
MetricsModule,
|
||||
CallbacksModule,
|
||||
DialplanModule,
|
||||
OutboundRoutesModule,
|
||||
AsteriskAdminModule,
|
||||
PauseReasonsModule,
|
||||
QueuesModule,
|
||||
|
||||
@@ -14,6 +14,10 @@ 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';
|
||||
|
||||
@@ -155,7 +159,19 @@ export class DialplanService {
|
||||
const entries = await this.prisma.dialplanEntry.findMany({
|
||||
where: { enabled: true },
|
||||
});
|
||||
const newConfig = generateDialplanConfig(entries);
|
||||
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 },
|
||||
@@ -173,7 +189,7 @@ export class DialplanService {
|
||||
);
|
||||
}
|
||||
|
||||
const failureReason = await this.verify(entries);
|
||||
const failureReason = await this.verify(verifyContexts);
|
||||
|
||||
if (failureReason) {
|
||||
// Rollback automático — nunca deixa uma configuração inválida ativa.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
// Mesmas restrições de caractere aplicadas em outbound-route-generator.ts —
|
||||
// validadas também no DTO para dar erro amigável antes de tentar publicar.
|
||||
export class CreateOutboundRouteDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(80)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[0-9]*$/, { message: 'Prefixo deve conter apenas dígitos.' })
|
||||
prefix?: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^[\]0-9XZN.![-]+$/i, {
|
||||
message:
|
||||
'Padrão inválido. Use apenas dígitos, X (0-9), Z (1-9), N (2-9), faixas [1-5], . e !.',
|
||||
})
|
||||
matchPattern!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[0-9+]*$/, {
|
||||
message: 'Prepend deve conter apenas dígitos (e opcionalmente +).',
|
||||
})
|
||||
prepend?: string;
|
||||
|
||||
@IsUUID('4')
|
||||
trunkId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
order?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateOutboundRouteDto } from './create-outbound-route.dto';
|
||||
|
||||
export class UpdateOutboundRouteDto extends PartialType(
|
||||
CreateOutboundRouteDto,
|
||||
) {}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
generateOutboundRoutesConfig,
|
||||
isValidMatchPattern,
|
||||
isValidPrefix,
|
||||
isValidPrepend,
|
||||
} from './outbound-route-generator';
|
||||
|
||||
describe('generateOutboundRoutesConfig', () => {
|
||||
it('remove o prefixo do número e substitui pelo prepend antes de discar', () => {
|
||||
const config = generateOutboundRoutesConfig([
|
||||
{
|
||||
name: 'Celular via tronco A',
|
||||
prefix: '0',
|
||||
matchPattern: 'NXXXXXXXXX',
|
||||
prepend: '55',
|
||||
order: 0,
|
||||
trunk: { name: 'tronco-a' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(config).toContain('exten => _0NXXXXXXXXX,1,');
|
||||
expect(config).toContain('Dial(PJSIP/55${EXTEN:1}@tronco-a,60)');
|
||||
});
|
||||
|
||||
it('sem prefixo, usa o número inteiro sem substring', () => {
|
||||
const config = generateOutboundRoutesConfig([
|
||||
{
|
||||
name: 'Direto',
|
||||
prefix: '',
|
||||
matchPattern: 'NXXXXXXXXX',
|
||||
prepend: null,
|
||||
order: 0,
|
||||
trunk: { name: 'tronco-b' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(config).toContain('exten => _NXXXXXXXXX,1,');
|
||||
expect(config).toContain('Dial(PJSIP/${EXTEN}@tronco-b,60)');
|
||||
});
|
||||
|
||||
it('inclui o contexto de rotas em b2bcall-agents', () => {
|
||||
const config = generateOutboundRoutesConfig([]);
|
||||
expect(config).toContain('[b2bcall-agents]');
|
||||
expect(config).toContain('include => b2bcall-outbound-routes');
|
||||
});
|
||||
|
||||
it('ordena rotas pelo campo order', () => {
|
||||
const config = generateOutboundRoutesConfig([
|
||||
{ name: 'Segunda', prefix: '', matchPattern: 'X.', prepend: null, order: 5, trunk: { name: 't2' } },
|
||||
{ name: 'Primeira', prefix: '', matchPattern: 'X.', prepend: null, order: 1, trunk: { name: 't1' } },
|
||||
]);
|
||||
expect(config.indexOf('Primeira')).toBeLessThan(config.indexOf('Segunda'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidMatchPattern', () => {
|
||||
it('aceita sintaxe de padrão do Asterisk', () => {
|
||||
expect(isValidMatchPattern('NXXXXXXXXX')).toBe(true);
|
||||
expect(isValidMatchPattern('[1-5]XX')).toBe(true);
|
||||
expect(isValidMatchPattern('X.')).toBe(true);
|
||||
});
|
||||
it('rejeita regex livre / caracteres não suportados', () => {
|
||||
expect(isValidMatchPattern('\\d{10}')).toBe(false);
|
||||
expect(isValidMatchPattern('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidPrefix / isValidPrepend', () => {
|
||||
it('prefixo aceita só dígitos (vazio incluso)', () => {
|
||||
expect(isValidPrefix('')).toBe(true);
|
||||
expect(isValidPrefix('09')).toBe(true);
|
||||
expect(isValidPrefix('9a')).toBe(false);
|
||||
});
|
||||
it('prepend aceita dígitos e +', () => {
|
||||
expect(isValidPrepend('+55')).toBe(true);
|
||||
expect(isValidPrepend('55')).toBe(true);
|
||||
expect(isValidPrepend('abc')).toBe(false);
|
||||
});
|
||||
});
|
||||
75
apps/api/src/outbound-routes/outbound-route-generator.ts
Normal file
75
apps/api/src/outbound-routes/outbound-route-generator.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Só os caracteres de padrão de extensão que o Asterisk realmente entende
|
||||
// (agente.md seção 21) — nunca aceitar regex livre aqui, o valor cai direto
|
||||
// num "exten =>" gerado.
|
||||
// X = 0-9 Z = 1-9 N = 2-9 [1-5] = faixa . = 1+ dígitos ! = 0+ dígitos
|
||||
const PATTERN_CHARS = /^[\]0-9XZN.![-]+$/i;
|
||||
const PREFIX_CHARS = /^[0-9]*$/;
|
||||
const PREPEND_CHARS = /^[0-9+]*$/;
|
||||
|
||||
export function isValidMatchPattern(pattern: string): boolean {
|
||||
return pattern.length > 0 && PATTERN_CHARS.test(pattern);
|
||||
}
|
||||
|
||||
export function isValidPrefix(prefix: string): boolean {
|
||||
return PREFIX_CHARS.test(prefix);
|
||||
}
|
||||
|
||||
export function isValidPrepend(prepend: string): boolean {
|
||||
return PREPEND_CHARS.test(prepend);
|
||||
}
|
||||
|
||||
export interface OutboundRouteWithTrunk {
|
||||
name: string;
|
||||
prefix: string;
|
||||
matchPattern: string;
|
||||
prepend: string | null;
|
||||
order: number;
|
||||
trunk: { name: string };
|
||||
}
|
||||
|
||||
export const OUTBOUND_ROUTES_CONTEXT = 'b2bcall-outbound-routes';
|
||||
|
||||
/**
|
||||
* Gera o contexto de rotas de saída + o include em "b2bcall-agents" (mesmo
|
||||
* contexto padrão de Extension.context) — mesma ideia do Issabel: o
|
||||
* prefixo é sempre removido do número discado e substituído pelo prepend
|
||||
* antes de mandar para o tronco, "mascarando" a forma como o Asterisk
|
||||
* recebe o número em relação ao que o agente realmente discou.
|
||||
*/
|
||||
export function generateOutboundRoutesConfig(
|
||||
routes: OutboundRouteWithTrunk[],
|
||||
): string {
|
||||
const sorted = [...routes].sort(
|
||||
(a, b) => a.order - b.order || a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
const lines: string[] = [
|
||||
'; Rotas de saída geradas automaticamente por OutboundRoutesService — NÃO EDITAR À MÃO.',
|
||||
'',
|
||||
`[${OUTBOUND_ROUTES_CONTEXT}]`,
|
||||
];
|
||||
|
||||
for (const route of sorted) {
|
||||
const fullPattern = `_${route.prefix}${route.matchPattern}`;
|
||||
const stripLen = route.prefix.length;
|
||||
const dialedNumber = stripLen > 0 ? `\${EXTEN:${stripLen}}` : '${EXTEN}';
|
||||
const sentNumber = `${route.prepend ?? ''}${dialedNumber}`;
|
||||
lines.push(`; Rota: ${route.name}`);
|
||||
lines.push(
|
||||
`exten => ${fullPattern},1,NoOp(Rota de saida: ${route.name} -> tronco ${route.trunk.name})`,
|
||||
);
|
||||
lines.push(` same => n,Dial(PJSIP/${sentNumber}@${route.trunk.name},60)`);
|
||||
lines.push(' same => n,Hangup()');
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Extensões usam context="b2bcall-agents" por padrão (ver Extension
|
||||
// model) — inclui as rotas de saída ali para que discagem manual do
|
||||
// agente (não o discador preditivo, que já sabe o tronco via Campaign)
|
||||
// funcione sem exigir dialplan escrito à mão.
|
||||
lines.push('[b2bcall-agents]');
|
||||
lines.push(`include => ${OUTBOUND_ROUTES_CONTEXT}`);
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
71
apps/api/src/outbound-routes/outbound-routes.controller.ts
Normal file
71
apps/api/src/outbound-routes/outbound-routes.controller.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { RequirePermissions } from '../common/decorators/permissions.decorator';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../common/guards/auth.guard';
|
||||
import { OutboundRoutesService } from './outbound-routes.service';
|
||||
import { CreateOutboundRouteDto } from './dto/create-outbound-route.dto';
|
||||
import { UpdateOutboundRouteDto } from './dto/update-outbound-route.dto';
|
||||
|
||||
// Reaproveita as permissões de dialplan (dialplans.*) — rota de saída é
|
||||
// uma camada amigável sobre o mesmo recurso (dialplan gerado/versionado).
|
||||
@Controller('outbound-routes')
|
||||
export class OutboundRoutesController {
|
||||
constructor(private readonly service: OutboundRoutesService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermissions('dialplans.view')
|
||||
list() {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermissions('dialplans.create')
|
||||
create(
|
||||
@Body() dto: CreateOutboundRouteDto,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.service.create(dto, user, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermissions('dialplans.update')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateOutboundRouteDto,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.service.update(id, dto, user, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermissions('dialplans.delete')
|
||||
remove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.service.remove(id, user, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
}
|
||||
9
apps/api/src/outbound-routes/outbound-routes.module.ts
Normal file
9
apps/api/src/outbound-routes/outbound-routes.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OutboundRoutesController } from './outbound-routes.controller';
|
||||
import { OutboundRoutesService } from './outbound-routes.service';
|
||||
|
||||
@Module({
|
||||
controllers: [OutboundRoutesController],
|
||||
providers: [OutboundRoutesService],
|
||||
})
|
||||
export class OutboundRoutesModule {}
|
||||
119
apps/api/src/outbound-routes/outbound-routes.service.ts
Normal file
119
apps/api/src/outbound-routes/outbound-routes.service.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import type { RequestContext } from '../auth/auth.service';
|
||||
import { CreateOutboundRouteDto } from './dto/create-outbound-route.dto';
|
||||
import { UpdateOutboundRouteDto } from './dto/update-outbound-route.dto';
|
||||
|
||||
@Injectable()
|
||||
export class OutboundRoutesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
) {}
|
||||
|
||||
list() {
|
||||
return this.prisma.outboundRoute.findMany({
|
||||
orderBy: [{ order: 'asc' }, { name: 'asc' }],
|
||||
include: { trunk: { select: { id: true, name: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
private async assertTrunkExists(trunkId: string) {
|
||||
const trunk = await this.prisma.trunk.findUnique({
|
||||
where: { id: trunkId },
|
||||
});
|
||||
if (!trunk) throw new BadRequestException('Tronco informado não existe.');
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateOutboundRouteDto,
|
||||
actor: { id: string },
|
||||
ctx: RequestContext,
|
||||
) {
|
||||
await this.assertTrunkExists(dto.trunkId);
|
||||
|
||||
const existing = await this.prisma.outboundRoute.findUnique({
|
||||
where: { name: dto.name },
|
||||
});
|
||||
if (existing)
|
||||
throw new BadRequestException('Já existe uma rota com este nome.');
|
||||
|
||||
const route = await this.prisma.outboundRoute.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
prefix: dto.prefix ?? '',
|
||||
matchPattern: dto.matchPattern,
|
||||
prepend: dto.prepend,
|
||||
trunkId: dto.trunkId,
|
||||
order: dto.order ?? 0,
|
||||
enabled: dto.enabled ?? true,
|
||||
},
|
||||
include: { trunk: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'outbound_route_created',
|
||||
entityType: 'outbound_route',
|
||||
entityId: route.id,
|
||||
after: { ...dto },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return route;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateOutboundRouteDto,
|
||||
actor: { id: string },
|
||||
ctx: RequestContext,
|
||||
) {
|
||||
const before = await this.prisma.outboundRoute.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!before) throw new NotFoundException('Rota de saída não encontrada.');
|
||||
|
||||
if (dto.trunkId) await this.assertTrunkExists(dto.trunkId);
|
||||
|
||||
const route = await this.prisma.outboundRoute.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
include: { trunk: { select: { id: true, name: true } } },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'outbound_route_updated',
|
||||
entityType: 'outbound_route',
|
||||
entityId: id,
|
||||
before,
|
||||
after: { ...dto },
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return route;
|
||||
}
|
||||
|
||||
async remove(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||
const route = await this.prisma.outboundRoute.findUnique({ where: { id } });
|
||||
if (!route) throw new NotFoundException('Rota de saída não encontrada.');
|
||||
|
||||
await this.prisma.outboundRoute.delete({ where: { id } });
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'outbound_route_deleted',
|
||||
entityType: 'outbound_route',
|
||||
entityId: id,
|
||||
before: route,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user