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
156 lines
5.5 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|