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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user