Files
B2BCall-dialer/apps/api/src/inbound-routes/inbound-routes.controller.ts
Matheus 738bfa4f36 feat(rotas-entrada): dropdown de destino real (ramal/IVR/fila/grupo)
Pedido do usuário: "em vez de o campo de destino ser aberto tem que ter
um dropdown listando todos os ramais do tennant e tb todas ivr e filas
e grupos de ramais do tennant" — até aqui o destino era texto livre.

Construir o dropdown revelou que fila e grupo NUNCA tinham sido
implementados de verdade como destino possível — só ramal/IVR
funcionavam. Oferecer as duas opções sem o mecanismo por trás seria
mostrar um dropdown mentiroso, então:

InboundRoute.destinationType (novo enum EXTENSION/IVR/QUEUE/CALL_GROUP)
decide como buildInboundRouteXml interpreta o destino:
- EXTENSION/IVR: inalterado, o mesmo transfer já testado (PHASE 56/58).
- QUEUE: destinationNumber guarda o Queue.id; a resolução de entrada
  emite `answer` + `callcenter data="<queueId>@<domain>"` direto, sem
  tocar no dialplan "default". `callcenter` entrou no allowlist de
  applications com o mesmo risco zero de `pickup`.
- CALL_GROUP: destinationNumber guarda o Extension.callGroup; a
  resolução consulta AGORA (nunca um snapshot salvo) todos os ramais
  com esse callGroup e emite um `bridge` multi-leg — toca todos ao
  mesmo tempo, quem atender primeiro cancela os outros. Trocar quem
  está no grupo depois de criar a rota já vale na próxima chamada.

Testado ponta a ponta com chamadas reais nos 2 mecanismos novos: fila —
softphone externo discou o DID, show channels confirmou a chamada
dentro da application callcenter com o nome certo da fila; grupo — 2
ramais reais no mesmo callGroup, a chamada tocou nos DOIS ao mesmo
tempo (mesmo call_uuid, ambas RINGING), atender em um cancelou o outro
automaticamente — ring group de verdade.

Tela: "Tipo de destino" + um segundo dropdown com as opções reais do
tenant pra cada tipo (ramais, menus de IVR, filas, ou os valores
distintos de callGroup já usados em algum ramal). Testado com
Playwright: os 4 tipos aparecem, e trocar o tipo atualiza as opções do
segundo dropdown corretamente.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 20:13:55 -03:00

156 lines
5.5 KiB
TypeScript

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,
destinationType: dto.destinationType ?? "EXTENSION",
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.destinationType !== undefined ? { destinationType: dto.destinationType } : {}),
...(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,
});
}
}