diff --git a/TODO.md b/TODO.md index 0aa2d93..6d20a98 100644 --- a/TODO.md +++ b/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 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 416b92d..7fe7ea2 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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, diff --git a/apps/api/src/inbound-routes/dto/create-inbound-route.dto.ts b/apps/api/src/inbound-routes/dto/create-inbound-route.dto.ts new file mode 100644 index 0000000..4ddef31 --- /dev/null +++ b/apps/api/src/inbound-routes/dto/create-inbound-route.dto.ts @@ -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; +} diff --git a/apps/api/src/inbound-routes/inbound-routes.controller.ts b/apps/api/src/inbound-routes/inbound-routes.controller.ts new file mode 100644 index 0000000..aef5e0e --- /dev/null +++ b/apps/api/src/inbound-routes/inbound-routes.controller.ts @@ -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, + }); + } +} diff --git a/apps/api/src/inbound-routes/inbound-routes.module.ts b/apps/api/src/inbound-routes/inbound-routes.module.ts new file mode 100644 index 0000000..febc3ab --- /dev/null +++ b/apps/api/src/inbound-routes/inbound-routes.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { InboundRoutesController } from "./inbound-routes.controller"; + +@Module({ + controllers: [InboundRoutesController], +}) +export class InboundRoutesModule {} diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts index 174274f..980011d 100644 --- a/apps/freeswitch-config/src/main.ts +++ b/apps/freeswitch-config/src/main.ts @@ -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 { + 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 { 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; } diff --git a/apps/frontend/src/app/app/telefonia/rotas-entrada/actions.ts b/apps/frontend/src/app/app/telefonia/rotas-entrada/actions.ts new file mode 100644 index 0000000..219e58d --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/rotas-entrada/actions.ts @@ -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("/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(`/inbound-routes/${id}`, session.accessToken, { method: "DELETE" }); + revalidatePath("/app/telefonia/rotas-entrada"); + return { ok: true }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/app/telefonia/rotas-entrada/page.tsx b/apps/frontend/src/app/app/telefonia/rotas-entrada/page.tsx new file mode 100644 index 0000000..b094bef --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/rotas-entrada/page.tsx @@ -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("/inbound-routes", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/app/telefonia/rotas-entrada/rotas-entrada-view.tsx b/apps/frontend/src/app/app/telefonia/rotas-entrada/rotas-entrada-view.tsx new file mode 100644 index 0000000..729dc87 --- /dev/null +++ b/apps/frontend/src/app/app/telefonia/rotas-entrada/rotas-entrada-view.tsx @@ -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 ( +
+
+
+

Rotas de entrada

+

+ 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. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + + + {routes.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {routes.map((r) => ( + + + + + + + + + ))} + +
DIDDescriçãoDestinoStatusCriada + Ações +
+ + + {r.didNumber} + + {r.description ?? "—"} + {r.destinationNumber} ({r.destinationContext}) + + {r.enabled ? "Ativa" : "Desativada"} + {formatDate(r.createdAt)} + +
+ )} +
+
+ ); +} + +function NewInboundRouteForm({ onDone }: { onDone: () => void }) { + const [didNumber, setDidNumber] = useState(""); + const [description, setDescription] = useState(""); + const [destinationNumber, setDestinationNumber] = useState(""); + const [error, setError] = useState(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 ( + +
+
+
+ Número (DID) + setDidNumber(e.target.value)} + placeholder="Ex.: 551140028922" + disabled={pending} + /> +
+
+ Ramal de destino + setDestinationNumber(e.target.value)} + placeholder="Ex.: 1001" + disabled={pending} + /> +
+
+ Descrição (opcional) + setDescription(e.target.value)} + placeholder="Ex.: Linha principal" + disabled={pending} + /> +
+
+ {error && ( +

+ {error} +

+ )} +
+ +
+
+
+ ); +} + +function DeleteInboundRouteButton({ routeId, didNumber }: { routeId: string; didNumber: string }) { + const router = useRouter(); + const [confirming, setConfirming] = useState(false); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(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 ( +
+ {error && {error}} + +
+ ); +} diff --git a/apps/frontend/src/components/tenant-shell/nav-data.ts b/apps/frontend/src/components/tenant-shell/nav-data.ts index 86d025b..6af0cd9 100644 --- a/apps/frontend/src/components/tenant-shell/nav-data.ts +++ b/apps/frontend/src/components/tenant-shell/nav-data.ts @@ -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", diff --git a/apps/frontend/src/lib/callcenter-types.ts b/apps/frontend/src/lib/callcenter-types.ts index 5bcbcd2..9a9ff90 100644 --- a/apps/frontend/src/lib/callcenter-types.ts +++ b/apps/frontend/src/lib/callcenter-types.ts @@ -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; diff --git a/docs/INBOUND_ROUTES.md b/docs/INBOUND_ROUTES.md new file mode 100644 index 0000000..39cf1f6 --- /dev/null +++ b/docs/INBOUND_ROUTES.md @@ -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=`, + `set domain_name=` (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 XML + `. 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. diff --git a/infrastructure/freeswitch/Dockerfile b/infrastructure/freeswitch/Dockerfile index 5539cc0..9b0d717 100644 --- a/infrastructure/freeswitch/Dockerfile +++ b/infrastructure/freeswitch/Dockerfile @@ -91,6 +91,23 @@ RUN sed -i \ && grep -q '' /etc/freeswitch/autoload_configs/switch.conf.xml \ && grep -q '' /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###' \ + /etc/freeswitch/sip_profiles/external.xml \ + && grep -q '' /etc/freeswitch/sip_profiles/external.xml + COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh diff --git a/packages/auth/src/seed.ts b/packages/auth/src/seed.ts index ba41c10..ab9a990 100644 --- a/packages/auth/src/seed.ts +++ b/packages/auth/src/seed.ts @@ -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 = { "dashboard.view", "extensions.view", "trunks.view", + "inbound_routes.view", "agents.view", "agents.manage", "queues.view", diff --git a/packages/database/prisma/migrations/20260830170000_inbound_routes/migration.sql b/packages/database/prisma/migrations/20260830170000_inbound_routes/migration.sql new file mode 100644 index 0000000..839aa2b --- /dev/null +++ b/packages/database/prisma/migrations/20260830170000_inbound_routes/migration.sql @@ -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); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index c982be9..6d9311f 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -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 diff --git a/packages/telephony/src/dialplan-xml.ts b/packages/telephony/src/dialplan-xml.ts index 76fda68..13387bd 100644 --- a/packages/telephony/src/dialplan-xml.ts +++ b/packages/telephony/src/dialplan-xml.ts @@ -118,6 +118,50 @@ function actionsXml(tag: "action" | "anti-action", actions: DialplanAction[] | u * múltiplas 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 ` + +
+ + + + + + + + + +
+
`; +} + export function buildDialplanXml(context: string, extensions: DialplanExtensionInput[]): string { const sorted = [...extensions].sort((a, b) => a.order - b.order);