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
176 lines
6.8 KiB
TypeScript
176 lines
6.8 KiB
TypeScript
/**
|
|
* Seed de RBAC (agente.md secoes 142, 145, 199, 200): catálogo de
|
|
* permissions, roles de sistema, e o Platform Super Admin inicial.
|
|
*
|
|
* Idempotente — seguro rodar de novo (upsert por chave única). Roda com:
|
|
* pnpm --filter @b2bcall/auth run seed
|
|
*/
|
|
import { randomBytes } from "node:crypto";
|
|
import { writeFileSync, chmodSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { getPrismaClient } from "@b2bcall/database";
|
|
import { hashPassword } from "./password";
|
|
|
|
// Catálogo completo de permissions (agente.md secao 145).
|
|
const PERMISSIONS: Array<{ key: string; description: string }> = [
|
|
{ key: "tenants.view", description: "Ver tenants (plataforma)" },
|
|
{ key: "tenants.manage", description: "Criar/editar/suspender tenants" },
|
|
{ key: "billing.view", description: "Ver consumo e faturas" },
|
|
{ key: "billing.manage", description: "Gerenciar fechamentos de billing" },
|
|
{ key: "pricing.manage", description: "Gerenciar planos e price books" },
|
|
{ key: "dashboard.view", description: "Ver dashboards" },
|
|
{ key: "extensions.view", description: "Ver ramais" },
|
|
{ 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" },
|
|
{ key: "queues.manage", description: "Criar/editar filas" },
|
|
{ key: "campaigns.view", description: "Ver campanhas" },
|
|
{ key: "campaigns.create", description: "Criar campanhas" },
|
|
{ key: "campaigns.update", description: "Editar campanhas" },
|
|
{ key: "campaigns.start", description: "Iniciar campanhas" },
|
|
{ key: "campaigns.pause", description: "Pausar campanhas" },
|
|
{ key: "campaigns.stop", description: "Parar campanhas" },
|
|
{ key: "monitoring.view", description: "Ver monitoramento em tempo real" },
|
|
{ key: "reports.view", description: "Ver relatórios" },
|
|
{ key: "reports.export", description: "Exportar relatórios" },
|
|
{ key: "recordings.view", description: "Ver gravações" },
|
|
{ key: "recordings.download", description: "Baixar gravações" },
|
|
{ key: "ai.view", description: "Ver análises de IA" },
|
|
{ key: "ai.manage", description: "Configurar providers/prompts de IA" },
|
|
{ key: "ai.analyze", description: "Disparar análise de IA manualmente" },
|
|
{ key: "freeswitch.view", description: "Ver estado do FreeSWITCH" },
|
|
{ key: "freeswitch.configure", description: "Configurar FreeSWITCH" },
|
|
{ key: "users.manage", description: "Gerenciar usuários" },
|
|
{ key: "roles.manage", description: "Gerenciar roles/permissões" },
|
|
{ key: "audit.view", description: "Ver audit log" },
|
|
];
|
|
|
|
// Mapeamento inicial role -> permissions. Ponto de partida razoável;
|
|
// revisar quando existir uma UI de administração de RBAC.
|
|
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
|
platform_super_admin: PERMISSIONS.map((p) => p.key), // tudo
|
|
tenant_admin: PERMISSIONS.map((p) => p.key).filter(
|
|
(key) => !["tenants.view", "tenants.manage", "pricing.manage"].includes(key),
|
|
),
|
|
supervisor: [
|
|
"dashboard.view",
|
|
"extensions.view",
|
|
"trunks.view",
|
|
"inbound_routes.view",
|
|
"agents.view",
|
|
"agents.manage",
|
|
"queues.view",
|
|
"campaigns.view",
|
|
"campaigns.update",
|
|
"campaigns.start",
|
|
"campaigns.pause",
|
|
"campaigns.stop",
|
|
"monitoring.view",
|
|
"reports.view",
|
|
"reports.export",
|
|
"recordings.view",
|
|
"recordings.download",
|
|
"ai.view",
|
|
],
|
|
agent: ["dashboard.view", "campaigns.view"],
|
|
};
|
|
|
|
const SYSTEM_ROLES: Array<{ key: string; name: string; scope: "PLATFORM" | "TENANT" }> = [
|
|
{ key: "platform_super_admin", name: "Platform Super Admin", scope: "PLATFORM" },
|
|
{ key: "tenant_admin", name: "Tenant Admin", scope: "TENANT" },
|
|
{ key: "supervisor", name: "Supervisor", scope: "TENANT" },
|
|
{ key: "agent", name: "Agente", scope: "TENANT" },
|
|
];
|
|
|
|
const PLATFORM_ADMIN_EMAIL = "admin@b2bcall.local";
|
|
|
|
async function main() {
|
|
const prisma = getPrismaClient();
|
|
|
|
for (const permission of PERMISSIONS) {
|
|
await prisma.permission.upsert({
|
|
where: { key: permission.key },
|
|
update: { description: permission.description },
|
|
create: permission,
|
|
});
|
|
}
|
|
console.log(`Permissions: ${PERMISSIONS.length} sincronizadas.`);
|
|
|
|
for (const role of SYSTEM_ROLES) {
|
|
const created = await prisma.role.upsert({
|
|
where: { key: role.key },
|
|
update: { name: role.name, scope: role.scope, isSystem: true },
|
|
create: { ...role, isSystem: true },
|
|
});
|
|
|
|
const permissionKeys = ROLE_PERMISSIONS[role.key] ?? [];
|
|
const permissions = await prisma.permission.findMany({
|
|
where: { key: { in: permissionKeys } },
|
|
});
|
|
|
|
await prisma.rolePermission.deleteMany({ where: { roleId: created.id } });
|
|
if (permissions.length > 0) {
|
|
await prisma.rolePermission.createMany({
|
|
data: permissions.map((p) => ({ roleId: created.id, permissionId: p.id })),
|
|
skipDuplicates: true,
|
|
});
|
|
}
|
|
console.log(`Role '${role.key}': ${permissions.length} permissions.`);
|
|
}
|
|
|
|
const existingPlatformAdmin = await prisma.userRole.findFirst({
|
|
where: { tenantId: null, role: { key: "platform_super_admin" } },
|
|
});
|
|
|
|
if (existingPlatformAdmin) {
|
|
console.log("Platform Super Admin já existe, pulando criação.");
|
|
} else {
|
|
const platformSuperAdminRole = await prisma.role.findUniqueOrThrow({
|
|
where: { key: "platform_super_admin" },
|
|
});
|
|
|
|
const initialPassword = randomBytes(18).toString("base64url");
|
|
const passwordHash = await hashPassword(initialPassword);
|
|
|
|
const admin = await prisma.user.upsert({
|
|
where: { email: PLATFORM_ADMIN_EMAIL },
|
|
update: {},
|
|
create: {
|
|
email: PLATFORM_ADMIN_EMAIL,
|
|
passwordHash,
|
|
name: "Platform Super Admin",
|
|
mustChangePassword: true,
|
|
},
|
|
});
|
|
|
|
await prisma.userRole.create({
|
|
data: { userId: admin.id, roleId: platformSuperAdminRole.id, tenantId: null },
|
|
});
|
|
|
|
const firstLoginPath = resolve(__dirname, "../../../FIRST_LOGIN.txt");
|
|
writeFileSync(
|
|
firstLoginPath,
|
|
`B2BCall — Platform Super Admin (gerado em ${new Date().toISOString()})\n` +
|
|
`Email: ${PLATFORM_ADMIN_EMAIL}\n` +
|
|
`Senha: ${initialPassword}\n\n` +
|
|
`Troca de senha OBRIGATÓRIA no primeiro login. Apague este arquivo depois de guardar a senha em um local seguro.\n`,
|
|
);
|
|
chmodSync(firstLoginPath, 0o600);
|
|
|
|
console.log(`Platform Super Admin criado: ${PLATFORM_ADMIN_EMAIL}`);
|
|
console.log(`Senha salva em ${firstLoginPath} (permissao 600) — nao sera exibida no terminal.`);
|
|
}
|
|
|
|
await prisma.$disconnect();
|
|
}
|
|
|
|
main().catch(async (err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|