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:
75
TODO.md
75
TODO.md
@@ -1996,6 +1996,81 @@ de fora da rede Docker e não conseguiu)
|
||||
precisam estar liberadas lá também — só o `iptables` local foi
|
||||
confirmado
|
||||
|
||||
## PHASE 55 — Status de registro de ramal em tempo real (monitoramento)
|
||||
(pedido do usuário: "antes de ir para ivr no monitoramento tem que
|
||||
mostrar quantos ramais estao online e o status de cada ramal criado")
|
||||
- [x] `Extension.registeredAt` (novo, nullable) — preenchido pelo
|
||||
`apps/freeswitch-events` a cada `sofia::register`/`sofia::unregister`/
|
||||
`sofia::expire` (já consumidos por esse serviço pra outros fins),
|
||||
mais uma reconciliação completa (`show registrations`) ao
|
||||
conectar/reconectar no ESL. `apps/api` roda no host e não alcança
|
||||
`freeswitch:8021` diretamente (rede interna do Docker), por isso a
|
||||
escrita vem de fs-events, mesmo padrão já usado em `Trunk.status`
|
||||
- [x] `GET /extensions` devolve `registeredAt`; `/app/monitoramento`
|
||||
ganhou painel "Ramais" com status ao vivo (online/offline, desde
|
||||
quando, grupo de captura) via os mesmos eventos SSE
|
||||
`EXTENSION_REGISTERED`/`EXTENSION_UNREGISTERED` que já existiam
|
||||
(só apareciam no log de eventos, nunca como estado atual)
|
||||
- [x] Testado contra o container real: reconciliação confirmada marcando
|
||||
online os 2 ramais que já estavam registrados antes do restart do
|
||||
fs-events; `GET /extensions` confirmado devolvendo `registeredAt`
|
||||
correto (null em ramal recém-criado sem registro)
|
||||
|
||||
## PHASE 56 — Rotas de entrada por DID (fundação pro IVR, docs/INBOUND_ROUTES.md)
|
||||
(pedido do usuário: "pode iniciar a montar o IVR e as rotas de entrada")
|
||||
- [x] achado real (bloqueava TUDO de chamada de entrada, não só IVR):
|
||||
nenhuma chamada que chega por um tronco carregava
|
||||
`b2bcall_tenant_id` — só REGISTER de ramal e discagem de saída
|
||||
setam essa variable. Sem ela, `resolveDialplanXml` sempre devolvia
|
||||
"not found" pra qualquer chamada de entrada
|
||||
- [x] achado real #2: mesmo corrigindo isso, o profile `external` (onde
|
||||
trunks recebem chamada) apontava pro contexto `public` vanilla —
|
||||
um ARQUIVO ESTÁTICO (`dialplan/public.xml`). Config estática sempre
|
||||
ganha de uma consulta ao `mod_xml_curl`, então esse contexto nunca
|
||||
seria dinâmico enquanto se chamasse "public". Repontado pra
|
||||
`context="inbound"` (sem arquivo estático nenhum) no Dockerfile
|
||||
- [x] `InboundRoute` (nova tabela, RLS real com FORCE) — decisão do
|
||||
usuário: granularidade por DID/número (não por tronco), pra um
|
||||
tronco poder carregar vários números com destinos diferentes.
|
||||
`didNumber` é `@unique` GLOBAL de propósito (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 em
|
||||
`apps/freeswitch-config` (nunca uma query sem contexto de RLS)
|
||||
- [x] `buildInboundRouteXml` (`packages/telephony`) gera o XML mínimo do
|
||||
contexto `inbound`: injeta `b2bcall_tenant_id` + `domain_name`
|
||||
(achado real #3: sem setar `domain_name` explicitamente, o
|
||||
`bridge data="user/${destination_number}@${domain_name}"` da regra
|
||||
"Discagem interna" resolvia pro domínio GLOBAL default, não pro do
|
||||
tenant — a leg de entrada não é ramal registrado, nada preenche
|
||||
essa variable sozinho) e transfere pro contexto/destino do tenant,
|
||||
reaproveitando 100% do dialplan já existente (inclusive pickup de
|
||||
grupo, PHASE 53)
|
||||
- [x] `InboundRoutesController` (CRUD completo, permissions
|
||||
`inbound_routes.view`/`.manage` novas no seed de RBAC) + tela
|
||||
"Telefonia > Rotas de Entrada" no frontend (mesmo padrão de
|
||||
Troncos: lista + form inline + remoção com confirmação de 2
|
||||
cliques)
|
||||
- [x] Testado ponta a ponta com uma chamada REAL (não só leitura de
|
||||
código): softphone registrado como ramal normal + um segundo
|
||||
softphone discando DIRETO pro profile `external` (porta 5080, sem
|
||||
registrar — exatamente como um provedor de tronco manda) um DID
|
||||
cadastrado numa `InboundRoute`. Confirmado via `show channels`: a
|
||||
chamada resolveu o tenant certo, transferiu pro contexto certo,
|
||||
bridged com o domínio certo do tenant (não o global), codec PCMU
|
||||
negociado nos dois lados, ramal tocou e atendeu de verdade.
|
||||
(Uma tentativa inicial com `originate loopback/.../inbound` deu
|
||||
`INCOMPATIBLE_DESTINATION` — isolado como limitação do canal
|
||||
`loopback` sem SDP real, não um bug da resolução; ver
|
||||
docs/INBOUND_ROUTES.md pro isolamento completo)
|
||||
- [ ] IVR em si (menu com `play_and_get_digits` + branching por dígito
|
||||
coletado) ainda não construído — é a próxima fase. Precisa
|
||||
widening de `ALLOWED_CONDITION_FIELDS` (hoje um enum fixo) pra
|
||||
aceitar `${variavel}` com a MESMA proteção anti-RCE já aplicada em
|
||||
`data` (secao 180), já que `field` também é expandido pelo
|
||||
FreeSWITCH em tempo de chamada. Ver "O que falta" em
|
||||
docs/INBOUND_ROUTES.md
|
||||
|
||||
---
|
||||
|
||||
## Riscos conhecidos
|
||||
|
||||
@@ -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,
|
||||
|
||||
54
apps/api/src/inbound-routes/dto/create-inbound-route.dto.ts
Normal file
54
apps/api/src/inbound-routes/dto/create-inbound-route.dto.ts
Normal 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;
|
||||
}
|
||||
153
apps/api/src/inbound-routes/inbound-routes.controller.ts
Normal file
153
apps/api/src/inbound-routes/inbound-routes.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
apps/api/src/inbound-routes/inbound-routes.module.ts
Normal file
7
apps/api/src/inbound-routes/inbound-routes.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { InboundRoutesController } from "./inbound-routes.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [InboundRoutesController],
|
||||
})
|
||||
export class InboundRoutesModule {}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
|
||||
100
docs/INBOUND_ROUTES.md
Normal file
100
docs/INBOUND_ROUTES.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Rotas de entrada (PHASE 56)
|
||||
|
||||
Pedido do usuário: "pode iniciar a montar o IVR e as rotas de entrada". A
|
||||
investigação achou que **nenhuma chamada de tronco tinha como funcionar
|
||||
hoje**, IVR ou não — esse documento cobre a fundação (rota por DID →
|
||||
tenant); o IVR em si (menu com `play_and_get_digits`) é a próxima fase,
|
||||
construída em cima disso.
|
||||
|
||||
## O achado real
|
||||
|
||||
Toda chamada originada de um ramal registrado ou de discagem de saída
|
||||
carrega o channel variable `b2bcall_tenant_id` (setado por
|
||||
`buildDirectoryUserXml`/originate do discador). **Uma chamada de ENTRADA
|
||||
por tronco nunca carregava isso** — não existia nenhum mecanismo pra
|
||||
identificar de qual tenant uma chamada de entrada é, então
|
||||
`resolveDialplanXml` (`apps/freeswitch-config`) sempre devolvia
|
||||
"not found" pra ela.
|
||||
|
||||
Pior: mesmo se essa variable existisse, o profile `external` do
|
||||
FreeSWITCH (onde troncos recebem chamada) aponta pro contexto `public`
|
||||
por padrão — um **arquivo estático** (`dialplan/public.xml`, vanilla).
|
||||
Config estática sempre ganha de uma consulta ao `mod_xml_curl`, então
|
||||
mesmo trocando a aplicação, esse contexto nunca seria dinâmico enquanto
|
||||
se chamasse "public".
|
||||
|
||||
## O mecanismo
|
||||
|
||||
1. **`InboundRoute`** (nova tabela, tenant-scoped, RLS real) — cada linha
|
||||
é `didNumber` (o número que o provedor manda no INVITE) → `tenantId` +
|
||||
`destinationContext`/`destinationNumber` (pra onde a chamada cai
|
||||
dentro do dialplan do TENANT DONO). `didNumber` é `@unique` **global**
|
||||
— mesma exceção já aceita em `Tenant.telephonyDomain` (PHASE 50): é a
|
||||
ÚNICA forma de descobrir de qual tenant é uma chamada de entrada
|
||||
ANTES de saber o tenant.
|
||||
|
||||
2. **Dockerfile**: o profile `external` foi repontado de `context="public"`
|
||||
pra `context="inbound"` — um contexto sintético, sem nenhum arquivo
|
||||
estático, então toda chamada de entrada cai obrigatoriamente no
|
||||
`mod_xml_curl` (`b2bcall-fs-config`, binding já genérico pra qualquer
|
||||
contexto do section `dialplan`). `dialplan/public.xml` continua no
|
||||
disco, só não é mais alcançado por nenhum profile.
|
||||
|
||||
3. **`apps/freeswitch-config`**: quando `resolveDialplanXml` recebe uma
|
||||
requisição SEM `variable_b2bcall_tenant_id` e com `Caller-Context ==
|
||||
"inbound"`, lê `Caller-Destination-Number` (o DID discado) e faz
|
||||
fan-out sobre os tenants ativos (`withTenantContext` por tenant, nunca
|
||||
uma query sem contexto — `InboundRoute` tem `FORCE ROW LEVEL
|
||||
SECURITY` de verdade) até achar quem é dono do DID.
|
||||
|
||||
4. **`buildInboundRouteXml`** (`packages/telephony`) gera um XML de
|
||||
dialplan mínimo pro contexto `inbound`: `set b2bcall_tenant_id=<id>`,
|
||||
`set domain_name=<domínio do tenant>` (achado real testando com
|
||||
chamada de verdade — sem isso o `bridge
|
||||
data="user/${destination_number}@${domain_name}"` da regra "Discagem
|
||||
interna" resolvia pro domínio GLOBAL default, nunca pro do tenant,
|
||||
porque a leg de entrada não é um ramal registrado e nada preenche essa
|
||||
variable sozinho), e `transfer <destinationNumber> XML
|
||||
<destinationContext>`. O `transfer` dispara uma nova consulta de
|
||||
dialplan — agora já com `b2bcall_tenant_id` presente — reaproveitando
|
||||
100% do dialplan normal do tenant (a mesma regra "Discagem interna" já
|
||||
testada pro pickup de grupo, PHASE 53).
|
||||
|
||||
## Testado ponta a ponta com uma chamada real
|
||||
|
||||
Nenhum tronco PSTN real disponível neste laboratório — simulado com dois
|
||||
softphones reais (`linphone-cli`) em containers Docker na mesma rede: um
|
||||
registrado como ramal normal (`8001@acme.b2bcall.net`), outro discando
|
||||
DIRETO pro profile `external` (porta 5080, sem registrar — exatamente
|
||||
como um provedor de tronco manda uma chamada) um número (DID) cadastrado
|
||||
numa `InboundRoute` apontando pra `8001`. Confirmado via `show channels`:
|
||||
a chamada de entrada resolveu o tenant certo, transferiu pro contexto
|
||||
`default` do tenant, bridged com o `user/8001@acme.b2bcall.net` (domínio
|
||||
correto, não o global) e ficou `ACTIVE` com codec PCMU negociado nos dois
|
||||
lados — ramal tocou, atendeu, áudio bidirecional confirmado.
|
||||
|
||||
(Tentativa inicial usando `originate loopback/.../inbound &park()` como
|
||||
simulação deu `INCOMPATIBLE_DESTINATION`/488 — isolado como limitação do
|
||||
canal `loopback` (sem SDP/codec real negociado), não um bug da resolução:
|
||||
confirmado testando a mesma resolução com destino simples
|
||||
`answer`+`playback` via loopback (sucesso) e depois com uma chamada SIP
|
||||
de verdade ponta a ponta (sucesso completo, áudio incluído).)
|
||||
|
||||
## O que falta
|
||||
|
||||
- IVR (menu com `play_and_get_digits` + branching por dígito) — a rota de
|
||||
entrada já pode apontar `destinationContext` pra um contexto de IVR
|
||||
dedicado, mas o "menu" em si (dialplan `play_and_get_digits` +
|
||||
condição sobre o dígito coletado) ainda não foi construído. Pontos em
|
||||
aberto: `ALLOWED_CONDITION_FIELDS` hoje é um enum fixo (nunca aceita
|
||||
`${variavel}` arbitrária) — vai precisar de um allowlist mais amplo com
|
||||
a MESMA proteção contra RCE já aplicada em `data` (`IsSafeDialplanData`),
|
||||
já que `field` também é expandido pelo FreeSWITCH em tempo de chamada.
|
||||
- Tela de frontend cobre só CRUD simples (DID → ramal); não tem seletor
|
||||
de "fila" ou "IVR" como destino ainda — hoje é só texto livre pro
|
||||
`destinationContext`/`destinationNumber` (o operador escolhe o contexto
|
||||
e número certos manualmente).
|
||||
- Perda das proteções de toll-fraud do `public.xml` vanilla (unroll de
|
||||
loop de chamada, etc.) — não replicadas no contexto `inbound` novo.
|
||||
Aceitável pra esta fase (sem trunks reais ainda), mas revisar antes de
|
||||
conectar um provedor PSTN de verdade.
|
||||
@@ -91,6 +91,23 @@ RUN sed -i \
|
||||
&& grep -q '<param name="rtp-start-port" value="16384"/>' /etc/freeswitch/autoload_configs/switch.conf.xml \
|
||||
&& grep -q '<param name="rtp-end-port" value="16584"/>' /etc/freeswitch/autoload_configs/switch.conf.xml
|
||||
|
||||
# Rotas de entrada por DID (PHASE 56, docs/INBOUND_ROUTES.md) — achado
|
||||
# real: nenhuma chamada que chega pelo profile "external" carrega
|
||||
# `b2bcall_tenant_id` (só REGISTER de ramal e discagem de saída setam essa
|
||||
# variable), então uma chamada de entrada não tem como saber de qual
|
||||
# tenant é. O contexto "public" vanilla (pra onde o profile "external"
|
||||
# aponta por padrão) é um ARQUIVO ESTÁTICO (`dialplan/public.xml`) — a
|
||||
# config estática sempre ganha da consulta ao mod_xml_curl, então esse
|
||||
# contexto NUNCA seria dinâmico enquanto se chamar "public". Renomeado
|
||||
# pra "inbound" (sem arquivo estático nenhum): toda chamada de entrada
|
||||
# passa a cair no mod_xml_curl (b2bcall-fs-config), que resolve o dono do
|
||||
# DID e injeta o tenant antes de encaminhar (packages/telephony
|
||||
# buildInboundRouteXml). `dialplan/public.xml` fica no lugar, sem efeito
|
||||
# — não precisa remover, só não é mais alcançado por nenhum profile.
|
||||
RUN sed -i 's#<param name="context" value="public"/>#<param name="context" value="inbound"/>#' \
|
||||
/etc/freeswitch/sip_profiles/external.xml \
|
||||
&& grep -q '<param name="context" value="inbound"/>' /etc/freeswitch/sip_profiles/external.xml
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user