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