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:
2026-08-27 20:13:21 -03:00
parent 80e72881b2
commit 9857904c6d
16 changed files with 885 additions and 4 deletions

View File

@@ -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 || '<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 o prefixo, o tronco recebe o número
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() {
const { can } = useAuth();
const { toast } = useToast();
@@ -335,11 +654,15 @@ function DialplanContent() {
return (
<>
<PageHeader title="Dialplan" description="Plano de discagem estruturado e versionado" />
<Tabs defaultValue="entries">
<Tabs defaultValue="routes">
<TabsList>
<TabsTrigger value="routes">Rotas de Saída</TabsTrigger>
<TabsTrigger value="entries">Entradas</TabsTrigger>
<TabsTrigger value="versions">Versões</TabsTrigger>
</TabsList>
<TabsContent value="routes">
<RoutesTab />
</TabsContent>
<TabsContent value="entries">
<EntriesTab />
</TabsContent>

View 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}`),
};

View File

@@ -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'