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:
2026-08-30 16:45:18 -03:00
parent fc4f5db13a
commit ed4ae4421e
17 changed files with 862 additions and 6 deletions

View File

@@ -3,6 +3,7 @@ import { HealthModule } from "./health/health.module";
import { AuthModule } from "./auth/auth.module";
import { ExtensionsModule } from "./extensions/extensions.module";
import { TrunksModule } from "./trunks/trunks.module";
import { InboundRoutesModule } from "./inbound-routes/inbound-routes.module";
import { DialplanModule } from "./dialplan/dialplan.module";
import { QueuesModule } from "./queues/queues.module";
import { AgentsModule } from "./agents/agents.module";
@@ -29,6 +30,7 @@ import { PlansModule } from "./plans/plans.module";
AuthModule,
ExtensionsModule,
TrunksModule,
InboundRoutesModule,
DialplanModule,
QueuesModule,
AgentsModule,

View File

@@ -0,0 +1,54 @@
import { IsBoolean, IsOptional, IsString, Matches, MaxLength } from "class-validator";
export class CreateInboundRouteDto {
// Numero como o provedor de troncos manda no INVITE (destination_number) —
// normalmente so digitos (E.164 sem "+" ou o formato local do provedor).
// Unico entre TODOS os tenants (ver InboundRoute no schema): dois tenants
// nunca podem reivindicar o mesmo DID.
@IsString()
@Matches(/^[0-9]{2,20}$/, { message: "didNumber deve ter só dígitos (2 a 20)" })
didNumber!: string;
@IsOptional()
@IsString()
@MaxLength(255)
description?: string;
// Contexto de dialplan do PRÓPRIO tenant que recebe a chamada depois da
// resolução — normalmente "default" (cai na discagem interna existente),
// ou um contexto de IVR dedicado.
@IsOptional()
@IsString()
@MaxLength(80)
destinationContext?: string;
// destination_number sintético usado dentro desse contexto — numero de
// ramal real, ou um destino reservado do menu de IVR.
@IsString()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
destinationNumber!: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}
export class UpdateInboundRouteDto {
@IsOptional()
@IsString()
@MaxLength(255)
description?: string;
@IsOptional()
@IsString()
@MaxLength(80)
destinationContext?: string;
@IsOptional()
@Matches(/^[a-zA-Z0-9_-]{1,40}$/, { message: "destinationNumber deve ser alfanumérico (1 a 40 caracteres)" })
destinationNumber?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}

View File

@@ -0,0 +1,153 @@
import {
Body,
ConflictException,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { getPrismaClient, withTenantContext, Prisma } from "@b2bcall/database";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateInboundRouteDto, UpdateInboundRouteDto } from "./dto/create-inbound-route.dto";
/**
* Rotas de entrada por DID (PHASE 56, docs/INBOUND_ROUTES.md) — achado
* real: nenhuma chamada de tronco carregava `b2bcall_tenant_id` até aqui,
* então uma chamada de entrada não tinha como saber de qual tenant é.
* `didNumber` é @unique GLOBAL de propósito (mesma exceção já aceita em
* `Tenant.telephonyDomain`) — por isso o conflito de duplicidade só
* aparece no INSERT (a constraint do banco), nunca por uma pré-checagem
* cross-tenant: `InboundRoute` tem RLS de verdade (FORCE ROW LEVEL
* SECURITY), então uma query sem contexto de tenant não veria a linha de
* outro tenant mesmo se tentasse.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("inbound-routes")
export class InboundRoutesController {
@RequirePermission("inbound_routes.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateInboundRouteDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
try {
const route = await withTenantContext(prisma, tenantId, (tx) =>
tx.inboundRoute.create({
data: {
tenantId,
didNumber: dto.didNumber,
description: dto.description,
destinationContext: dto.destinationContext ?? "default",
destinationNumber: dto.destinationNumber,
enabled: dto.enabled ?? true,
},
}),
);
await recordAuditEvent(prisma, {
action: "INBOUND_ROUTE_CREATE",
tenantId,
userId: user.sub,
entityType: "inbound_route",
entityId: route.id,
after: { didNumber: route.didNumber, destinationContext: route.destinationContext, destinationNumber: route.destinationNumber },
});
return route;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") {
throw new ConflictException("Este número (DID) já está em uso por outra rota de entrada");
}
throw err;
}
}
@RequirePermission("inbound_routes.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
return withTenantContext(prisma, tenantId, (tx) =>
tx.inboundRoute.findMany({ where: { deletedAt: null }, orderBy: { didNumber: "asc" } }),
);
}
@RequirePermission("inbound_routes.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const route = await withTenantContext(prisma, tenantId, (tx) =>
tx.inboundRoute.findFirst({ where: { id, deletedAt: null } }),
);
if (!route) throw new NotFoundException();
return route;
}
@RequirePermission("inbound_routes.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateInboundRouteDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.inboundRoute.updateMany({
where: { id, tenantId, deletedAt: null },
data: {
...(dto.description !== undefined ? { description: dto.description } : {}),
...(dto.destinationContext !== undefined ? { destinationContext: dto.destinationContext } : {}),
...(dto.destinationNumber !== undefined ? { destinationNumber: dto.destinationNumber } : {}),
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),
},
}),
);
if (result.count === 0) throw new NotFoundException();
const updated = await withTenantContext(prisma, tenantId, (tx) => tx.inboundRoute.findFirstOrThrow({ where: { id } }));
await recordAuditEvent(prisma, {
action: "INBOUND_ROUTE_UPDATE",
tenantId,
userId: user.sub,
entityType: "inbound_route",
entityId: id,
after: { ...dto },
});
return updated;
}
@RequirePermission("inbound_routes.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.inboundRoute.updateMany({
where: { id, deletedAt: null },
data: { deletedAt: new Date(), enabled: false },
}),
);
if (result.count === 0) throw new NotFoundException();
await recordAuditEvent(prisma, {
action: "INBOUND_ROUTE_DELETE",
tenantId,
userId: user.sub,
entityType: "inbound_route",
entityId: id,
});
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { InboundRoutesController } from "./inbound-routes.controller";
@Module({
controllers: [InboundRoutesController],
})
export class InboundRoutesModule {}

View File

@@ -4,7 +4,7 @@ import formbody from "@fastify/formbody";
import Redis from "ioredis";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { decryptSecret } from "@b2bcall/shared";
import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
import { buildDirectoryUserXml, buildInboundRouteXml, NOT_FOUND_XML } from "@b2bcall/telephony";
import { createLogger } from "@b2bcall/shared";
import { syncTrunks } from "./trunk-sync";
import { syncQueues } from "./queue-sync";
@@ -81,13 +81,53 @@ async function resolveDirectoryXml(user: string | undefined, domain: string | un
});
}
/**
* Resolve o tenant dono de um DID pra uma chamada de ENTRADA (PHASE 56,
* docs/INBOUND_ROUTES.md) — achado real: uma chamada chegando pelo profile
* "external" (contexto "inbound", renomeado do "public" vanilla no
* Dockerfile) nunca carrega `variable_b2bcall_tenant_id`, porque essa
* variable só é setada em REGISTER de ramal ou originate de discagem
* (nunca em chamada recebida de tronco). `InboundRoute.didNumber` é a
* ÚNICA forma de descobrir de qual tenant é ANTES de saber o tenant —
* por isso é único GLOBAL (mesma exceção de `Tenant.telephonyDomain`).
* Fan-out sobre tenants ativos com `withTenantContext` (nunca uma query
* sem contexto — `InboundRoute` tem RLS de verdade, FORCE ROW LEVEL
* SECURITY), mesmo padrão já usado em `updateTrunkStatusFromGatewayEvent`.
*/
async function resolveInboundRouteXml(didNumber: string | undefined): Promise<string> {
if (!didNumber) return NOT_FOUND_XML;
const prisma = getPrismaClient();
const tenants = await prisma.tenant.findMany({
where: { status: "ACTIVE", telephonyDomain: { not: null } },
select: { id: true, telephonyDomain: true },
});
for (const tenant of tenants) {
const route = await withTenantContext(prisma, tenant.id, (tx) =>
tx.inboundRoute.findFirst({ where: { tenantId: tenant.id, didNumber, enabled: true, deletedAt: null } }),
);
if (route) {
return buildInboundRouteXml({
tenantId: tenant.id,
domain: tenant.telephonyDomain!,
destinationNumber: route.destinationNumber,
destinationContext: route.destinationContext,
});
}
}
return NOT_FOUND_XML;
}
/**
* Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em
* toda chamada originada de um ramal nosso (ver buildDirectoryUserXml).
* Ao contrário do directory (resolvido por domain — cada tenant tem o seu
* agora, único no banco, PHASE 50/docs/EXTENSIONS.md), o dialplan já tem
* essa variável disponível na própria chamada, então nem depende de
* domain nenhum.
* toda chamada originada de um ramal nosso (ver buildDirectoryUserXml) ou,
* pra chamada de ENTRADA, pela própria resolução de rota acima (que seta
* a variable antes de transferir — o `transfer` dispara esta função de
* novo, já com o tenant presente). Ao contrário do directory (resolvido
* por domain — cada tenant tem o seu agora, único no banco, PHASE 50/
* docs/EXTENSIONS.md), o dialplan já tem essa variável disponível na
* própria chamada, então nem depende de domain nenhum.
*
* Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml)
* — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as
@@ -99,6 +139,10 @@ async function resolveDialplanXml(body: XmlCurlBody): Promise<string> {
const context = (body["Caller-Context"] ?? body.context ?? "default") as string;
if (!tenantId) {
if (context === "inbound") {
const did = (body["Caller-Destination-Number"] ?? body["Hunt-Destination-Number"]) as string | undefined;
return resolveInboundRouteXml(did);
}
return NOT_FOUND_XML;
}

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

View 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} />;
}

View File

@@ -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>
);
}

View File

@@ -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",

View File

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