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:
16
TODO.md
16
TODO.md
@@ -126,6 +126,22 @@ mestre original (`agente.md`, seções 90-93).
|
|||||||
versão anterior via endpoint dedicado. Limitação conhecida: detecta
|
versão anterior via endpoint dedicado. Limitação conhecida: detecta
|
||||||
falhas estruturais/sintáticas, não erros semânticos como nome de
|
falhas estruturais/sintáticas, não erros semânticos como nome de
|
||||||
aplicação Asterisk inexistente (só se manifesta em tempo de chamada)
|
aplicação Asterisk inexistente (só se manifesta em tempo de chamada)
|
||||||
|
- [x] Rotas de saída (`OutboundRoute`, aba "Dialplan → Rotas de Saída") —
|
||||||
|
**adicionado após a Fase 10**, a pedido do usuário: abstração amigável
|
||||||
|
estilo Issabel/FreePBX (prepend + prefix + match pattern + seleção de
|
||||||
|
tronco) sobre o dialplan bruto, sem exigir que o usuário escreva
|
||||||
|
`exten =>`/`Dial()` à mão. Prefixo sempre removido do número antes de
|
||||||
|
discar, substituído pelo prepend. Gera o contexto
|
||||||
|
`b2bcall-outbound-routes`, incluído em `b2bcall-agents` (contexto
|
||||||
|
padrão de `Extension.context`), publicado/versionado pelo mesmo
|
||||||
|
`DialplanService.publish()` das entradas brutas (mesma validação
|
||||||
|
`dialplan show`/rollback automático — um único `verify()` cobre os
|
||||||
|
dois). 6 testes unitários (`outbound-route-generator.spec.ts`) +
|
||||||
|
testado ponta a ponta contra o Asterisk real: rota `(55)+0|NXXXXXXXXX`
|
||||||
|
criada, publicada, `dialplan show b2bcall-outbound-routes` confirmou
|
||||||
|
`exten => _0NXXXXXXXXX` ativo com o `Dial()` mascarando o número
|
||||||
|
corretamente. Não usado pelo discador preditivo (campanhas já sabem o
|
||||||
|
tronco via `Campaign.trunkId` diretamente).
|
||||||
- [x] Administração do Asterisk: status (AMI + heartbeat asterisk-events),
|
- [x] Administração do Asterisk: status (AMI + heartbeat asterisk-events),
|
||||||
module show, e diagnóstico com ALLOWLIST ESTRITA de comandos exatos
|
module show, e diagnóstico com ALLOWLIST ESTRITA de comandos exatos
|
||||||
(core show uptime/channels/version, pjsip show *, queue show, module
|
(core show uptime/channels/version, pjsip show *, queue show, module
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { TrunksModule } from './trunks/trunks.module';
|
|||||||
import { ExtensionsModule } from './extensions/extensions.module';
|
import { ExtensionsModule } from './extensions/extensions.module';
|
||||||
import { MonitoringModule } from './monitoring/monitoring.module';
|
import { MonitoringModule } from './monitoring/monitoring.module';
|
||||||
import { DialplanModule } from './dialplan/dialplan.module';
|
import { DialplanModule } from './dialplan/dialplan.module';
|
||||||
|
import { OutboundRoutesModule } from './outbound-routes/outbound-routes.module';
|
||||||
import { AsteriskAdminModule } from './asterisk-admin/asterisk-admin.module';
|
import { AsteriskAdminModule } from './asterisk-admin/asterisk-admin.module';
|
||||||
import { PauseReasonsModule } from './pause-reasons/pause-reasons.module';
|
import { PauseReasonsModule } from './pause-reasons/pause-reasons.module';
|
||||||
import { QueuesModule } from './queues/queues.module';
|
import { QueuesModule } from './queues/queues.module';
|
||||||
@@ -78,6 +79,7 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
|||||||
MetricsModule,
|
MetricsModule,
|
||||||
CallbacksModule,
|
CallbacksModule,
|
||||||
DialplanModule,
|
DialplanModule,
|
||||||
|
OutboundRoutesModule,
|
||||||
AsteriskAdminModule,
|
AsteriskAdminModule,
|
||||||
PauseReasonsModule,
|
PauseReasonsModule,
|
||||||
QueuesModule,
|
QueuesModule,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import { TELEPHONY_PROVIDER } from '../telephony/telephony.module';
|
|||||||
import { CreateDialplanEntryDto } from './dto/create-dialplan-entry.dto';
|
import { CreateDialplanEntryDto } from './dto/create-dialplan-entry.dto';
|
||||||
import { UpdateDialplanEntryDto } from './dto/update-dialplan-entry.dto';
|
import { UpdateDialplanEntryDto } from './dto/update-dialplan-entry.dto';
|
||||||
import { generateDialplanConfig } from './dialplan-generator';
|
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';
|
const DIALPLAN_FILE_PATH = '/etc/asterisk-generated/b2bcall-dialplan.conf';
|
||||||
|
|
||||||
@@ -155,7 +159,19 @@ export class DialplanService {
|
|||||||
const entries = await this.prisma.dialplanEntry.findMany({
|
const entries = await this.prisma.dialplanEntry.findMany({
|
||||||
where: { enabled: true },
|
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({
|
const previous = await this.prisma.dialplanVersion.findFirst({
|
||||||
where: { status: DialplanVersionStatus.APPLIED },
|
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) {
|
if (failureReason) {
|
||||||
// Rollback automático — nunca deixa uma configuração inválida ativa.
|
// 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,19 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
import { useToast } from '@/components/ui/toast';
|
import { useToast } from '@/components/ui/toast';
|
||||||
import { useAuth } from '@/hooks/use-auth';
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
import { dialplanService, type DialplanEntryInput } from '@/services/dialplan';
|
import { dialplanService, type DialplanEntryInput } from '@/services/dialplan';
|
||||||
import type { DialplanEntry, DialplanVersion } from '@/types';
|
import { outboundRoutesService, type OutboundRouteInput } from '@/services/outbound-routes';
|
||||||
|
import { trunksService } from '@/services/trunks';
|
||||||
|
import type { DialplanEntry, DialplanVersion, OutboundRoute } from '@/types';
|
||||||
import { errorMessage } from '@/lib/error-message';
|
import { errorMessage } from '@/lib/error-message';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
import { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -270,6 +279,316 @@ function EntriesTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_ROUTE_FORM: OutboundRouteInput = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
prefix: '',
|
||||||
|
matchPattern: '',
|
||||||
|
prepend: '',
|
||||||
|
trunkId: '',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mesma lógica de apps/api/src/outbound-routes/outbound-route-generator.ts —
|
||||||
|
// só para preview, quem manda de verdade é o backend ao publicar.
|
||||||
|
function previewRoute(form: OutboundRouteInput, trunkName: string | undefined) {
|
||||||
|
const pattern = `_${form.prefix ?? ''}${form.matchPattern || '...'}`;
|
||||||
|
const stripLen = (form.prefix ?? '').length;
|
||||||
|
const dialed = stripLen > 0 ? `\${EXTEN:${stripLen}}` : '${EXTEN}';
|
||||||
|
const sent = `${form.prepend ?? ''}${dialed}`;
|
||||||
|
return `${pattern} → Dial(PJSIP/${sent}@${trunkName || '<tronco>'})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
route,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (v: boolean) => void;
|
||||||
|
route: OutboundRoute | null;
|
||||||
|
}) {
|
||||||
|
const isEdit = Boolean(route);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [form, setForm] = React.useState<OutboundRouteInput>(EMPTY_ROUTE_FORM);
|
||||||
|
|
||||||
|
const { data: trunks } = useQuery({
|
||||||
|
queryKey: ['trunks'],
|
||||||
|
queryFn: trunksService.list,
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setForm(
|
||||||
|
route
|
||||||
|
? {
|
||||||
|
name: route.name,
|
||||||
|
description: route.description ?? '',
|
||||||
|
prefix: route.prefix,
|
||||||
|
matchPattern: route.matchPattern,
|
||||||
|
prepend: route.prepend ?? '',
|
||||||
|
trunkId: route.trunkId,
|
||||||
|
order: route.order,
|
||||||
|
enabled: route.enabled,
|
||||||
|
}
|
||||||
|
: EMPTY_ROUTE_FORM,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [open, route]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
isEdit && route
|
||||||
|
? outboundRoutesService.update(route.id, form)
|
||||||
|
: outboundRoutesService.create(form),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['outbound-routes'] });
|
||||||
|
toast({ title: 'Rota salva', variant: 'success' });
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedTrunkName = trunks?.find((t) => t.id === form.trunkId)?.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{isEdit ? 'Editar rota de saída' : 'Nova rota de saída'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate();
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="route-name">Nome</Label>
|
||||||
|
<Input
|
||||||
|
id="route-name"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="route-description">Descrição</Label>
|
||||||
|
<Input
|
||||||
|
id="route-description"
|
||||||
|
value={form.description ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="route-prepend">Prepend</Label>
|
||||||
|
<Input
|
||||||
|
id="route-prepend"
|
||||||
|
placeholder="55"
|
||||||
|
value={form.prepend ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, prepend: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="route-prefix">Prefixo</Label>
|
||||||
|
<Input
|
||||||
|
id="route-prefix"
|
||||||
|
placeholder="0"
|
||||||
|
value={form.prefix ?? ''}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, prefix: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="route-pattern">Padrão (CalledID)</Label>
|
||||||
|
<Input
|
||||||
|
id="route-pattern"
|
||||||
|
required
|
||||||
|
placeholder="NXXXXXXXXX"
|
||||||
|
value={form.matchPattern}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, matchPattern: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground rounded-md border border-dashed px-3 py-2 font-mono text-xs">
|
||||||
|
{previewRoute(form, selectedTrunkName)}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
O prefixo é sempre removido do número antes de discar e substituído pelo
|
||||||
|
prepend — o Asterisk nunca vê o prefixo, só o tronco recebe o número já
|
||||||
|
mascarado. Padrão aceita dígitos, X (0-9), Z (1-9), N (2-9), faixas como
|
||||||
|
[1-5], . (um ou mais dígitos) e ! (zero ou mais).
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="route-trunk">Tronco</Label>
|
||||||
|
<Select value={form.trunkId} onValueChange={(v) => setForm((f) => ({ ...f, trunkId: v }))}>
|
||||||
|
<SelectTrigger id="route-trunk">
|
||||||
|
<SelectValue placeholder="Selecione..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{trunks?.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="route-order">Ordem</Label>
|
||||||
|
<Input
|
||||||
|
id="route-order"
|
||||||
|
type="number"
|
||||||
|
value={form.order ?? 0}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, order: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" loading={mutation.isPending} disabled={!form.trunkId}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoutesTab() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||||
|
const [editing, setEditing] = React.useState<OutboundRoute | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['outbound-routes'],
|
||||||
|
queryFn: outboundRoutesService.list,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = useMutation({
|
||||||
|
mutationFn: (id: string) => outboundRoutesService.remove(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['outbound-routes'] });
|
||||||
|
toast({ title: 'Rota removida', variant: 'success' });
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const publish = useMutation({
|
||||||
|
mutationFn: () => dialplanService.publish(),
|
||||||
|
onSuccess: (version) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dialplan-versions'] });
|
||||||
|
toast({
|
||||||
|
title: version.status === 'APPLIED' ? 'Dialplan publicado' : 'Falha ao publicar',
|
||||||
|
description: version.reloadResult ?? undefined,
|
||||||
|
variant: version.status === 'APPLIED' ? 'success' : 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => toast({ title: 'Erro', description: errorMessage(err), variant: 'destructive' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: DataTableColumn<OutboundRoute>[] = [
|
||||||
|
{ key: 'name', header: 'Nome', render: (r) => r.name },
|
||||||
|
{
|
||||||
|
key: 'pattern',
|
||||||
|
header: 'Máscara',
|
||||||
|
render: (r) => (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
({r.prepend || '—'}) + {r.prefix || '—'} | {r.matchPattern}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'trunk', header: 'Tronco', render: (r) => r.trunk.name },
|
||||||
|
{ key: 'order', header: 'Ordem', render: (r) => r.order },
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
header: 'Status',
|
||||||
|
render: (r) => (
|
||||||
|
<Badge variant={r.enabled ? 'success' : 'secondary'}>
|
||||||
|
{r.enabled ? 'Ativa' : 'Inativa'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
header: '',
|
||||||
|
className: 'text-right',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{can('dialplans.update') && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(r);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('dialplans.delete') && (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('Remover esta rota de saída?')) remove.mutate(r.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Rotas de saída: (prepend) + prefixo | padrão de casamento, no mesmo estilo do
|
||||||
|
Issabel/FreePBX — sem escrever dialplan à mão. O prefixo é sempre removido do
|
||||||
|
número antes de mandar ao tronco escolhido.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{can('dialplans.update') && (
|
||||||
|
<Button variant="outline" loading={publish.isPending} onClick={() => publish.mutate()}>
|
||||||
|
<UploadCloud /> Publicar
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{can('dialplans.create') && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus /> Nova rota
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
emptyMessage="Nenhuma rota de saída cadastrada."
|
||||||
|
/>
|
||||||
|
<RouteFormDialog open={dialogOpen} onOpenChange={setDialogOpen} route={editing} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VersionsTab() {
|
function VersionsTab() {
|
||||||
const { can } = useAuth();
|
const { can } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -335,11 +654,15 @@ function DialplanContent() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Dialplan" description="Plano de discagem estruturado e versionado" />
|
<PageHeader title="Dialplan" description="Plano de discagem estruturado e versionado" />
|
||||||
<Tabs defaultValue="entries">
|
<Tabs defaultValue="routes">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
|
<TabsTrigger value="routes">Rotas de Saída</TabsTrigger>
|
||||||
<TabsTrigger value="entries">Entradas</TabsTrigger>
|
<TabsTrigger value="entries">Entradas</TabsTrigger>
|
||||||
<TabsTrigger value="versions">Versões</TabsTrigger>
|
<TabsTrigger value="versions">Versões</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
<TabsContent value="routes">
|
||||||
|
<RoutesTab />
|
||||||
|
</TabsContent>
|
||||||
<TabsContent value="entries">
|
<TabsContent value="entries">
|
||||||
<EntriesTab />
|
<EntriesTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
21
apps/frontend/src/services/outbound-routes.ts
Normal file
21
apps/frontend/src/services/outbound-routes.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { OutboundRoute } from '@/types';
|
||||||
|
|
||||||
|
export interface OutboundRouteInput {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
prefix?: string;
|
||||||
|
matchPattern: string;
|
||||||
|
prepend?: string;
|
||||||
|
trunkId: string;
|
||||||
|
order?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const outboundRoutesService = {
|
||||||
|
list: () => api.get<OutboundRoute[]>('/outbound-routes'),
|
||||||
|
create: (input: OutboundRouteInput) => api.post<OutboundRoute>('/outbound-routes', input),
|
||||||
|
update: (id: string, input: Partial<OutboundRouteInput>) =>
|
||||||
|
api.patch<OutboundRoute>(`/outbound-routes/${id}`, input),
|
||||||
|
remove: (id: string) => api.delete<void>(`/outbound-routes/${id}`),
|
||||||
|
};
|
||||||
@@ -105,6 +105,21 @@ export interface DialplanVersion {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OutboundRoute {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
prefix: string;
|
||||||
|
matchPattern: string;
|
||||||
|
prepend: string | null;
|
||||||
|
trunkId: string;
|
||||||
|
trunk: { id: string; name: string };
|
||||||
|
order: number;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type QueueStrategy =
|
export type QueueStrategy =
|
||||||
| 'ringall'
|
| 'ringall'
|
||||||
| 'leastrecent'
|
| 'leastrecent'
|
||||||
|
|||||||
@@ -29,6 +29,27 @@ quando um Tronco/Ramal é criado/editado pela API.
|
|||||||
`DialplanService` a partir de `DialplanEntry`/`DialplanVersion`
|
`DialplanService` a partir de `DialplanEntry`/`DialplanVersion`
|
||||||
(Postgres). Toda alteração é uma nova `DialplanVersion` com validação
|
(Postgres). Toda alteração é uma nova `DialplanVersion` com validação
|
||||||
(`dialplan reload` + checagem de erro) e rollback automático se falhar.
|
(`dialplan reload` + checagem de erro) e rollback automático se falhar.
|
||||||
|
- **Rotas de saída** (`OutboundRoute`, tela "Dialplan → Rotas de Saída"):
|
||||||
|
abstração amigável no estilo Issabel/FreePBX sobre o dialplan bruto — o
|
||||||
|
usuário informa `prepend` + `prefix` + `matchPattern` (sintaxe de padrão
|
||||||
|
do Asterisk: `X`/`Z`/`N`/faixas `[1-5]`/`.`/`!`, sem o `_` inicial) e
|
||||||
|
escolhe o tronco, sem escrever `exten =>`/`Dial()` à mão
|
||||||
|
(`apps/api/src/outbound-routes/outbound-route-generator.ts`). Gera o
|
||||||
|
contexto `b2bcall-outbound-routes`:
|
||||||
|
```
|
||||||
|
exten => _<prefix><matchPattern>,1,NoOp(...)
|
||||||
|
same => n,Dial(PJSIP/<prepend>${EXTEN:<len(prefix)>}@<tronco>,60)
|
||||||
|
same => n,Hangup()
|
||||||
|
```
|
||||||
|
e inclui esse contexto em `[b2bcall-agents]` (contexto padrão de
|
||||||
|
`Extension.context`), para que a discagem manual de um agente já passe
|
||||||
|
pelas rotas sem configuração extra. O prefixo é **sempre** removido do
|
||||||
|
número antes de discar e substituído pelo prepend — o tronco nunca vê o
|
||||||
|
prefixo que o agente digitou, só o número já mascarado. Publicado e
|
||||||
|
versionado junto com `DialplanEntry` pelo mesmo `DialplanService.publish()`
|
||||||
|
(mesma validação `dialplan show`/rollback automático). Não usado pelo
|
||||||
|
discador preditivo — campanhas já sabem o tronco diretamente via
|
||||||
|
`Campaign.trunkId`, sem precisar casar padrão.
|
||||||
- `infrastructure/asterisk/config/queues.conf` inclui
|
- `infrastructure/asterisk/config/queues.conf` inclui
|
||||||
`/etc/asterisk-generated/b2bcall-queues.conf` — gerado a partir de
|
`/etc/asterisk-generated/b2bcall-queues.conf` — gerado a partir de
|
||||||
`Queue` (Postgres). **Membros nunca são estáticos** — adicionados/
|
`Queue` (Postgres). **Membros nunca são estáticos** — adicionados/
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "outbound_routes" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"prefix" TEXT NOT NULL DEFAULT '',
|
||||||
|
"match_pattern" TEXT NOT NULL,
|
||||||
|
"prepend" TEXT,
|
||||||
|
"trunk_id" TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "outbound_routes_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "outbound_routes_name_key" ON "outbound_routes"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "outbound_routes_order_idx" ON "outbound_routes"("order");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "outbound_routes" ADD CONSTRAINT "outbound_routes_trunk_id_fkey" FOREIGN KEY ("trunk_id") REFERENCES "trunks"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -181,6 +181,7 @@ model Trunk {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
campaigns Campaign[]
|
campaigns Campaign[]
|
||||||
|
outboundRoutes OutboundRoute[]
|
||||||
|
|
||||||
@@map("trunks")
|
@@map("trunks")
|
||||||
}
|
}
|
||||||
@@ -238,6 +239,34 @@ model DialplanEntry {
|
|||||||
@@map("dialplan_entries")
|
@@map("dialplan_entries")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rota de saída (agente.md seção 21) — abstração amigável sobre o dialplan
|
||||||
|
// bruto, no mesmo espírito de "Outbound Routes" do Issabel/FreePBX: o
|
||||||
|
// usuário informa prefixo + padrão de casamento (sintaxe de padrão do
|
||||||
|
// Asterisk: X/Z/N/faixas/coringas, sem o "_" inicial) + dígitos a
|
||||||
|
// prepender, e escolhe o tronco — sem escrever `exten =>`/`Dial()` à mão.
|
||||||
|
// O prefixo é sempre removido do número antes de discar (mascarado),
|
||||||
|
// substituído pelo prepend, exatamente como no Issabel. Compilada pelo
|
||||||
|
// DialplanService (junto com dialplan_entries) para o contexto gerado
|
||||||
|
// "b2bcall-outbound-routes", incluído em "b2bcall-agents".
|
||||||
|
model OutboundRoute {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
prefix String @default("")
|
||||||
|
matchPattern String @map("match_pattern")
|
||||||
|
prepend String?
|
||||||
|
trunkId String @map("trunk_id")
|
||||||
|
order Int @default(0)
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
trunk Trunk @relation(fields: [trunkId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@index([order])
|
||||||
|
@@map("outbound_routes")
|
||||||
|
}
|
||||||
|
|
||||||
enum DialplanVersionStatus {
|
enum DialplanVersionStatus {
|
||||||
APPLIED
|
APPLIED
|
||||||
FAILED
|
FAILED
|
||||||
|
|||||||
Reference in New Issue
Block a user