diff --git a/TODO.md b/TODO.md index cae6a4b..6c56e8e 100644 --- a/TODO.md +++ b/TODO.md @@ -126,6 +126,22 @@ mestre original (`agente.md`, seções 90-93). versão anterior via endpoint dedicado. Limitação conhecida: detecta falhas estruturais/sintáticas, não erros semânticos como nome de 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), module show, e diagnóstico com ALLOWLIST ESTRITA de comandos exatos (core show uptime/channels/version, pjsip show *, queue show, module diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a64c1f9..514a3b3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/dialplan/dialplan.service.ts b/apps/api/src/dialplan/dialplan.service.ts index a1f209f..ab676e5 100644 --- a/apps/api/src/dialplan/dialplan.service.ts +++ b/apps/api/src/dialplan/dialplan.service.ts @@ -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. diff --git a/apps/api/src/outbound-routes/dto/create-outbound-route.dto.ts b/apps/api/src/outbound-routes/dto/create-outbound-route.dto.ts new file mode 100644 index 0000000..9037dfc --- /dev/null +++ b/apps/api/src/outbound-routes/dto/create-outbound-route.dto.ts @@ -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; +} diff --git a/apps/api/src/outbound-routes/dto/update-outbound-route.dto.ts b/apps/api/src/outbound-routes/dto/update-outbound-route.dto.ts new file mode 100644 index 0000000..6b64896 --- /dev/null +++ b/apps/api/src/outbound-routes/dto/update-outbound-route.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateOutboundRouteDto } from './create-outbound-route.dto'; + +export class UpdateOutboundRouteDto extends PartialType( + CreateOutboundRouteDto, +) {} diff --git a/apps/api/src/outbound-routes/outbound-route-generator.spec.ts b/apps/api/src/outbound-routes/outbound-route-generator.spec.ts new file mode 100644 index 0000000..1e8d754 --- /dev/null +++ b/apps/api/src/outbound-routes/outbound-route-generator.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/outbound-routes/outbound-route-generator.ts b/apps/api/src/outbound-routes/outbound-route-generator.ts new file mode 100644 index 0000000..a10a189 --- /dev/null +++ b/apps/api/src/outbound-routes/outbound-route-generator.ts @@ -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'); +} diff --git a/apps/api/src/outbound-routes/outbound-routes.controller.ts b/apps/api/src/outbound-routes/outbound-routes.controller.ts new file mode 100644 index 0000000..8fdfd21 --- /dev/null +++ b/apps/api/src/outbound-routes/outbound-routes.controller.ts @@ -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'], + }); + } +} diff --git a/apps/api/src/outbound-routes/outbound-routes.module.ts b/apps/api/src/outbound-routes/outbound-routes.module.ts new file mode 100644 index 0000000..71eb95d --- /dev/null +++ b/apps/api/src/outbound-routes/outbound-routes.module.ts @@ -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 {} diff --git a/apps/api/src/outbound-routes/outbound-routes.service.ts b/apps/api/src/outbound-routes/outbound-routes.service.ts new file mode 100644 index 0000000..c5fae7e --- /dev/null +++ b/apps/api/src/outbound-routes/outbound-routes.service.ts @@ -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, + }); + } +} diff --git a/apps/frontend/src/app/(app)/dialplan/page.tsx b/apps/frontend/src/app/(app)/dialplan/page.tsx index 16638a3..9e2bab9 100644 --- a/apps/frontend/src/app/(app)/dialplan/page.tsx +++ b/apps/frontend/src/app/(app)/dialplan/page.tsx @@ -18,10 +18,19 @@ import { DialogTitle, DialogFooter, } from '@/components/ui/dialog'; +import { + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, +} from '@/components/ui/select'; import { useToast } from '@/components/ui/toast'; import { useAuth } from '@/hooks/use-auth'; 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 { 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 || ''})`; +} + +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(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 ( + + + + {isEdit ? 'Editar rota de saída' : 'Nova rota de saída'} + +
{ + e.preventDefault(); + mutation.mutate(); + }} + className="flex flex-col gap-4" + > +
+ + setForm((f) => ({ ...f, name: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, description: e.target.value }))} + /> +
+
+
+ + setForm((f) => ({ ...f, prepend: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, prefix: e.target.value }))} + /> +
+
+ + setForm((f) => ({ ...f, matchPattern: e.target.value }))} + /> +
+
+

+ {previewRoute(form, selectedTrunkName)} +

+

+ 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). +

+
+
+ + +
+
+ + setForm((f) => ({ ...f, order: Number(e.target.value) }))} + /> +
+
+ + + +
+
+
+ ); +} + +function RoutesTab() { + const { can } = useAuth(); + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [editing, setEditing] = React.useState(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[] = [ + { key: 'name', header: 'Nome', render: (r) => r.name }, + { + key: 'pattern', + header: 'Máscara', + render: (r) => ( + + ({r.prepend || '—'}) + {r.prefix || '—'} | {r.matchPattern} + + ), + }, + { key: 'trunk', header: 'Tronco', render: (r) => r.trunk.name }, + { key: 'order', header: 'Ordem', render: (r) => r.order }, + { + key: 'enabled', + header: 'Status', + render: (r) => ( + + {r.enabled ? 'Ativa' : 'Inativa'} + + ), + }, + { + key: 'actions', + header: '', + className: 'text-right', + render: (r) => ( +
+ {can('dialplans.update') && ( + + )} + {can('dialplans.delete') && ( + + )} +
+ ), + }, + ]; + + return ( +
+

+ 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. +

+
+ {can('dialplans.update') && ( + + )} + {can('dialplans.create') && ( + + )} +
+ refetch()} + rowKey={(r) => r.id} + emptyMessage="Nenhuma rota de saída cadastrada." + /> + +
+ ); +} + function VersionsTab() { const { can } = useAuth(); const { toast } = useToast(); @@ -335,11 +654,15 @@ function DialplanContent() { return ( <> - + + Rotas de Saída Entradas Versões + + + diff --git a/apps/frontend/src/services/outbound-routes.ts b/apps/frontend/src/services/outbound-routes.ts new file mode 100644 index 0000000..eae0205 --- /dev/null +++ b/apps/frontend/src/services/outbound-routes.ts @@ -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('/outbound-routes'), + create: (input: OutboundRouteInput) => api.post('/outbound-routes', input), + update: (id: string, input: Partial) => + api.patch(`/outbound-routes/${id}`, input), + remove: (id: string) => api.delete(`/outbound-routes/${id}`), +}; diff --git a/apps/frontend/src/types/index.ts b/apps/frontend/src/types/index.ts index 12b699a..6b84e73 100644 --- a/apps/frontend/src/types/index.ts +++ b/apps/frontend/src/types/index.ts @@ -105,6 +105,21 @@ export interface DialplanVersion { 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 = | 'ringall' | 'leastrecent' diff --git a/docs/ASTERISK.md b/docs/ASTERISK.md index 2518e59..3d259dc 100644 --- a/docs/ASTERISK.md +++ b/docs/ASTERISK.md @@ -29,6 +29,27 @@ quando um Tronco/Ramal é criado/editado pela API. `DialplanService` a partir de `DialplanEntry`/`DialplanVersion` (Postgres). Toda alteração é uma nova `DialplanVersion` com validação (`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 => _,1,NoOp(...) + same => n,Dial(PJSIP/${EXTEN:}@,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 `/etc/asterisk-generated/b2bcall-queues.conf` — gerado a partir de `Queue` (Postgres). **Membros nunca são estáticos** — adicionados/ diff --git a/packages/database/prisma/migrations/20260827225105_add_outbound_routes/migration.sql b/packages/database/prisma/migrations/20260827225105_add_outbound_routes/migration.sql new file mode 100644 index 0000000..507b724 --- /dev/null +++ b/packages/database/prisma/migrations/20260827225105_add_outbound_routes/migration.sql @@ -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; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 9d9d238..05f1fa9 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -181,6 +181,7 @@ model Trunk { updatedAt DateTime @updatedAt @map("updated_at") campaigns Campaign[] + outboundRoutes OutboundRoute[] @@map("trunks") } @@ -238,6 +239,34 @@ model DialplanEntry { @@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 { APPLIED FAILED