feat(telefonia): rotas de entrada por DID — fundação real pro IVR
Pedido do usuário: "pode iniciar a montar o IVR e as rotas de entrada". Investigando antes de escrever qualquer XML de IVR, achei que NENHUMA chamada de entrada por tronco tinha como funcionar hoje, IVR ou não: nenhuma carregava `b2bcall_tenant_id` (só REGISTER de ramal e discagem de saída setam essa variable), e mesmo corrigindo isso, o profile "external" apontava pro contexto "public" vanilla — um arquivo ESTÁTICO, que sempre ganha de uma consulta ao mod_xml_curl, então nunca seria dinâmico enquanto se chamasse "public". Perguntei ao usuário a granularidade certa (por DID ou por tronco) antes de desenhar o schema — escolheu por DID, mais flexível (um tronco pode carregar vários números com destinos diferentes). `InboundRoute` nova (RLS real, FORCE ROW LEVEL SECURITY): `didNumber` único GLOBAL entre tenants (mesma exceção já aceita em Tenant.telephonyDomain) — é a ÚNICA forma de descobrir de qual tenant é uma chamada de entrada ANTES de identificar o tenant. Resolvido por fan-out sobre tenants ativos, nunca uma query sem contexto de RLS. Dockerfile repontou o profile external pra context="inbound" (sem arquivo estático, cai no mod_xml_curl de verdade). O XML gerado pra esse contexto injeta b2bcall_tenant_id + domain_name (achado real: sem setar domain_name explicitamente, o bridge da "Discagem interna" resolvia pro domínio GLOBAL default, nunca pro do tenant) e transfere pro dialplan real do tenant — reaproveita 100% do que já existe, inclusive pickup de grupo (PHASE 53). CRUD completo (InboundRoutesController, permissions novas no seed) + tela "Telefonia > Rotas de Entrada" no frontend. Testado com uma chamada REAL: um softphone registrado como ramal normal, outro discando direto pro profile external (porta 5080, sem registrar — exatamente como um provedor de tronco manda) um DID cadastrado. `show channels` confirma: tenant certo, contexto certo, domínio certo no bridge, codec PCMU negociado nos dois lados, ramal tocou e atendeu de verdade. Detalhes completos, inclusive uma tentativa de teste que falhou por limitação do canal `loopback` (não um bug), em docs/INBOUND_ROUTES.md. O IVR em si (menu com play_and_get_digits) fica pra próxima fase — esta é a fundação sem a qual nada de chamada de entrada funcionava. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
54
apps/frontend/src/app/app/telefonia/rotas-entrada/actions.ts
Normal file
54
apps/frontend/src/app/app/telefonia/rotas-entrada/actions.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch, ApiError } from "@/lib/api";
|
||||
import type { InboundRoute } from "@/lib/callcenter-types";
|
||||
|
||||
function extractErrorMessage(err: unknown): string {
|
||||
if (err instanceof ApiError) {
|
||||
try {
|
||||
const parsed = JSON.parse(err.message);
|
||||
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
|
||||
if (typeof parsed.message === "string") return parsed.message;
|
||||
} catch {
|
||||
// corpo não era JSON
|
||||
}
|
||||
return err.message || "Falha inesperada na API.";
|
||||
}
|
||||
return "Falha inesperada. Tente novamente.";
|
||||
}
|
||||
|
||||
export interface CreateInboundRouteInput {
|
||||
didNumber: string;
|
||||
description?: string;
|
||||
destinationContext?: string;
|
||||
destinationNumber: string;
|
||||
}
|
||||
|
||||
export async function createInboundRoute(
|
||||
input: CreateInboundRouteInput,
|
||||
): Promise<{ ok: true; route: InboundRoute } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
const route = await apiFetch<InboundRoute>("/inbound-routes", session.accessToken, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
revalidatePath("/app/telefonia/rotas-entrada");
|
||||
return { ok: true, route };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteInboundRoute(id: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const session = await requireSession();
|
||||
try {
|
||||
await apiFetch<void>(`/inbound-routes/${id}`, session.accessToken, { method: "DELETE" });
|
||||
revalidatePath("/app/telefonia/rotas-entrada");
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: extractErrorMessage(err) };
|
||||
}
|
||||
}
|
||||
10
apps/frontend/src/app/app/telefonia/rotas-entrada/page.tsx
Normal file
10
apps/frontend/src/app/app/telefonia/rotas-entrada/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { requireSession } from "@/lib/session";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { InboundRoute } from "@/lib/callcenter-types";
|
||||
import { RotasEntradaView } from "./rotas-entrada-view";
|
||||
|
||||
export default async function RotasEntradaPage() {
|
||||
const session = await requireSession();
|
||||
const routes = await apiFetch<InboundRoute[]>("/inbound-routes", session.accessToken);
|
||||
return <RotasEntradaView routes={routes} />;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PhoneIncoming, Plus, Trash2, X } from "lucide-react";
|
||||
import { Panel, PanelHeader } from "@/components/ui/panel";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input, FieldLabel } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/ui/pill";
|
||||
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { InboundRoute } from "@/lib/callcenter-types";
|
||||
import { createInboundRoute, deleteInboundRoute } from "./actions";
|
||||
|
||||
export function RotasEntradaView({ routes }: { routes: InboundRoute[] }) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-foreground">Rotas de entrada</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Cada número (DID) que um tronco recebe vira uma rota própria, apontando pra um ramal, fila ou IVR — um
|
||||
tronco pode carregar vários números com destinos diferentes. O número (DID) é único entre todos os
|
||||
tenants: é a única forma de saber de quem é uma chamada de entrada antes de identificar o tenant.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={() => setShowForm((s) => !s)}>
|
||||
{showForm ? <X className="h-4 w-4" aria-hidden /> : <Plus className="h-4 w-4" aria-hidden />}
|
||||
{showForm ? "Cancelar" : "Nova rota"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && <NewInboundRouteForm onDone={() => setShowForm(false)} />}
|
||||
|
||||
<Panel>
|
||||
<PanelHeader title="Rotas cadastradas" description={`${routes.length} rota(s) neste tenant`} />
|
||||
{routes.length === 0 ? (
|
||||
<EmptyState title="Nenhuma rota de entrada cadastrada ainda" description="Crie a primeira rota deste tenant." />
|
||||
) : (
|
||||
<Table>
|
||||
<THead>
|
||||
<TR>
|
||||
<TH>DID</TH>
|
||||
<TH>Descrição</TH>
|
||||
<TH>Destino</TH>
|
||||
<TH>Status</TH>
|
||||
<TH>Criada</TH>
|
||||
<TH>
|
||||
<span className="sr-only">Ações</span>
|
||||
</TH>
|
||||
</TR>
|
||||
</THead>
|
||||
<TBody>
|
||||
{routes.map((r) => (
|
||||
<TR key={r.id}>
|
||||
<TD>
|
||||
<span className="flex items-center gap-2 font-mono font-medium text-foreground">
|
||||
<PhoneIncoming className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />
|
||||
{r.didNumber}
|
||||
</span>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{r.description ?? "—"}</TD>
|
||||
<TD className="font-mono text-muted-foreground">
|
||||
{r.destinationNumber} <span className="text-xs">({r.destinationContext})</span>
|
||||
</TD>
|
||||
<TD>
|
||||
<Pill tone={r.enabled ? "accent" : "neutral"}>{r.enabled ? "Ativa" : "Desativada"}</Pill>
|
||||
</TD>
|
||||
<TD className="text-muted-foreground">{formatDate(r.createdAt)}</TD>
|
||||
<TD>
|
||||
<DeleteInboundRouteButton routeId={r.id} didNumber={r.didNumber} />
|
||||
</TD>
|
||||
</TR>
|
||||
))}
|
||||
</TBody>
|
||||
</Table>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewInboundRouteForm({ onDone }: { onDone: () => void }) {
|
||||
const [didNumber, setDidNumber] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [destinationNumber, setDestinationNumber] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!didNumber.trim() || !destinationNumber.trim()) {
|
||||
setError("DID e destino são obrigatórios.");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const result = await createInboundRoute({
|
||||
didNumber: didNumber.trim(),
|
||||
description: description.trim() || undefined,
|
||||
destinationNumber: destinationNumber.trim(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
onDone();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel className="p-5">
|
||||
<form onSubmit={onSubmit} noValidate className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div>
|
||||
<FieldLabel htmlFor="ir-did">Número (DID)</FieldLabel>
|
||||
<Input
|
||||
id="ir-did"
|
||||
value={didNumber}
|
||||
onChange={(e) => setDidNumber(e.target.value)}
|
||||
placeholder="Ex.: 551140028922"
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="ir-destination">Ramal de destino</FieldLabel>
|
||||
<Input
|
||||
id="ir-destination"
|
||||
value={destinationNumber}
|
||||
onChange={(e) => setDestinationNumber(e.target.value)}
|
||||
placeholder="Ex.: 1001"
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel htmlFor="ir-description">Descrição (opcional)</FieldLabel>
|
||||
<Input
|
||||
id="ir-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Ex.: Linha principal"
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Criando…" : "Criar rota"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteInboundRouteButton({ routeId, didNumber }: { routeId: string; didNumber: string }) {
|
||||
const router = useRouter();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function onClick() {
|
||||
if (!confirming) {
|
||||
setConfirming(true);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const result = await deleteInboundRoute(routeId);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setConfirming(false);
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{error && <span className="text-xs text-destructive">{error}</span>}
|
||||
<Button
|
||||
type="button"
|
||||
variant={confirming ? "destructive" : "ghost"}
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
disabled={pending}
|
||||
aria-label={confirming ? `Confirmar remoção de ${didNumber}` : `Remover ${didNumber}`}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||
{confirming ? "Confirmar" : ""}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,6 +99,12 @@ export const TENANT_NAV: NavSection[] = [
|
||||
description: "Troncos SIP deste tenant",
|
||||
permission: "trunks.view",
|
||||
},
|
||||
{
|
||||
label: "Rotas de Entrada",
|
||||
href: "/app/telefonia/rotas-entrada",
|
||||
description: "Números (DID) recebidos por tronco — pra qual ramal/fila/IVR cada um cai",
|
||||
permission: "inbound_routes.view",
|
||||
},
|
||||
{
|
||||
label: "Dialplan",
|
||||
href: "/app/telefonia/dialplan",
|
||||
|
||||
@@ -47,6 +47,16 @@ export interface Trunk {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface InboundRoute {
|
||||
id: string;
|
||||
didNumber: string;
|
||||
description: string | null;
|
||||
destinationContext: string;
|
||||
destinationNumber: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface PauseReason {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user