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

@@ -23,6 +23,8 @@ const PERMISSIONS: Array<{ key: string; description: string }> = [
{ key: "extensions.manage", description: "Criar/editar ramais" },
{ key: "trunks.view", description: "Ver troncos" },
{ key: "trunks.manage", description: "Criar/editar troncos" },
{ key: "inbound_routes.view", description: "Ver rotas de entrada" },
{ key: "inbound_routes.manage", description: "Criar/editar rotas de entrada" },
{ key: "agents.view", description: "Ver agentes" },
{ key: "agents.manage", description: "Criar/editar agentes" },
{ key: "queues.view", description: "Ver filas" },
@@ -59,6 +61,7 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
"dashboard.view",
"extensions.view",
"trunks.view",
"inbound_routes.view",
"agents.view",
"agents.manage",
"queues.view",

View File

@@ -0,0 +1,37 @@
-- PHASE 56: rotas de entrada por DID (chamada de tronco -> tenant/destino)
CREATE TABLE "inbound_routes" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"did_number" TEXT NOT NULL,
"description" TEXT,
"destination_context" TEXT NOT NULL DEFAULT 'default',
"destination_number" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "inbound_routes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "inbound_routes_did_number_key" ON "inbound_routes"("did_number");
-- CreateIndex
CREATE INDEX "inbound_routes_tenant_id_idx" ON "inbound_routes"("tenant_id");
-- AddForeignKey
ALTER TABLE "inbound_routes" ADD CONSTRAINT "inbound_routes_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Tabela de negocio tenant-scoped: RLS obrigatorio (ver docs/TENANT_ISOLATION.md).
-- didNumber e' @unique GLOBAL de proposito (mesma excecao ja aceita em
-- Tenant.telephonyDomain) -- e' a UNICA forma de descobrir de qual tenant
-- e' uma chamada de entrada ANTES de identificar o tenant. A resolucao em
-- apps/freeswitch-config faz fan-out sobre tenants ativos com
-- withTenantContext (nunca uma query sem contexto), entao RLS continua
-- valendo de verdade aqui, igual toda outra tabela de negocio.
ALTER TABLE "inbound_routes" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "inbound_routes" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "inbound_routes"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

View File

@@ -65,6 +65,7 @@ model Tenant {
userRoles UserRole[]
extensions Extension[]
trunks Trunk[]
inboundRoutes InboundRoute[]
dialplanExtensions DialplanExtension[]
dialplanVersions DialplanVersion[]
queues Queue[]
@@ -442,6 +443,43 @@ model Trunk {
@@map("trunks")
}
// Rota de entrada por DID (PHASE 56) — achado real: nenhuma chamada que
// chega por um tronco carrega `b2bcall_tenant_id` hoje (só ramal
// registrado e discagem de saída setam essa variable), então uma chamada
// de entrada não tem como saber de qual tenant é. `didNumber` é a ÚNICA
// forma de descobrir isso ANTES de identificar o tenant — por isso é
// globalmente único entre TODOS os tenants (mesma exceção já aceita pra
// `Tenant.telephonyDomain`, secao 52), nunca dois tenants podem reivindicar
// o mesmo número. Resolvido por fan-out sobre tenants ativos em
// `apps/freeswitch-config` (mesmo padrão de `updateTrunkStatusFromGatewayEvent`),
// não por uma query sem RLS — ver docs/INBOUND_ROUTES.md.
model InboundRoute {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
didNumber String @unique @map("did_number")
description String?
// Contexto de dialplan do TENANT DONO que recebe a chamada depois da
// resolução (ex.: "default" pra cair direto na discagem interna já
// existente, ou um contexto de IVR dedicado) + o destination_number
// sintético usado dentro dele (número de ramal real, ou um destino
// reservado do menu de IVR).
destinationContext String @default("default") @map("destination_context")
destinationNumber String @map("destination_number")
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
@@index([tenantId])
@@map("inbound_routes")
}
enum DialplanVersionStatus {
DRAFT
ACTIVE

View File

@@ -118,6 +118,50 @@ function actionsXml(tag: "action" | "anti-action", actions: DialplanAction[] | u
* múltiplas <condition> por extension); cobre o editor estruturado descrito
* na especificação sem a complexidade de encadeamento arbitrário.
*/
/**
* Contexto sintético "inbound" (PHASE 56, docs/INBOUND_ROUTES.md) — o
* profile "external" do FreeSWITCH aponta pra este contexto (renomeado do
* "public" vanilla no Dockerfile), que NÃO tem arquivo estático, então
* toda chamada de entrada por tronco passa por aqui via mod_xml_curl.
* Diferente do dialplan do tenant (`buildDialplanXml`, versionado,
* editável), este XML é gerado direto pela resolução de
* `InboundRoute.didNumber` — nunca guardado em `dialplan_versions`. Faz só
* duas coisas: injeta `b2bcall_tenant_id` (nenhuma chamada de entrada
* carrega isso até aqui) e transfere pro destino real do tenant — o
* `transfer` dispara uma nova resolução de dialplan (agora já com a
* variable presente), reaproveitando 100% do dialplan normal do tenant
* (ex.: a regra "Discagem interna" já testada pro pickup de grupo).
*
* `domain_name` TAMBÉM precisa ser setado explicitamente — achado real
* testando com uma chamada de verdade: sem isso, o `bridge
* data="user/${"${destination_number}"}@${"${domain_name}"}"` da
* "Discagem interna" resolve `${"${domain_name}"}` pro domínio GLOBAL
* default (`$${domain}` do vars.xml, ex.: "b2bcall.local"), nunca pro
* domínio de verdade do tenant — a leg de entrada não é um ramal
* registrado, então nada preenche essa variable sozinho.
*/
export function buildInboundRouteXml(params: {
tenantId: string;
domain: string;
destinationNumber: string;
destinationContext: string;
}): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="dialplan">
<context name="inbound">
<extension name="inbound-route">
<condition>
<action application="set" data="b2bcall_tenant_id=${xmlEscape(params.tenantId)}"/>
<action application="set" data="domain_name=${xmlEscape(params.domain)}"/>
<action application="transfer" data="${xmlEscape(params.destinationNumber)} XML ${xmlEscape(params.destinationContext)}"/>
</condition>
</extension>
</context>
</section>
</document>`;
}
export function buildDialplanXml(context: string, extensions: DialplanExtensionInput[]): string {
const sorted = [...extensions].sort((a, b) => a.order - b.order);