feat(platform): Clientes > Tenants e Planos — CRUD real (backend novo)

Não existia NENHUM endpoint pra criar/listar/editar tenant nem plano até
aqui — só via script/seed ad hoc. Dois controllers novos: TenantsController
(/tenants) e PlansController (/plans), platform-only.

POST /tenants cria o tenant E o primeiro usuário (Tenant Admin) numa
transação só — sem esse usuário o tenant fica inacessível. Senha gerada e
devolvida em texto puro só na resposta de criação (revela uma vez, mesmo
padrão de Ramais/SIP).

Bug real achado testando o próprio endpoint: GET /tenants calculava
memberCount sem contexto de RLS — tenant_memberships tem FORCE RLS, então
nem platform admin enxerga linha nenhuma sem app.current_tenant_id
setado, o campo sempre voltava 0. Corrigido abrindo o contexto de cada
tenant um de cada vez.

Frontend: /platform/clientes/tenants (lista+busca), /tenants/new (cria
tenant+admin, revela senha), /tenants/:id (troca status/plano, preview
dos limites ao vivo), /platform/clientes/planos (CRUD completo dos
limites). Testado ponta a ponta: plano novo -> tenant novo com admin real
-> troca de plano persistida, confirmada via API. Smoke test nas 19 telas
anteriores, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-29 19:50:12 -03:00
parent 3479dbd007
commit 2c1269a83a
27 changed files with 1462 additions and 1 deletions

View File

@@ -20,6 +20,8 @@ import { AIModule } from "./ai/ai.module";
import { QualityModule } from "./quality/quality.module";
import { PlatformModule } from "./platform/platform.module";
import { BillingModule } from "./billing/billing.module";
import { TenantsModule } from "./tenants/tenants.module";
import { PlansModule } from "./plans/plans.module";
@Module({
imports: [
@@ -44,6 +46,8 @@ import { BillingModule } from "./billing/billing.module";
QualityModule,
PlatformModule,
BillingModule,
TenantsModule,
PlansModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,169 @@
import { IsBoolean, IsInt, IsOptional, IsString, Matches, MaxLength, Min } from "class-validator";
export class CreatePlanDto {
@IsString()
@MaxLength(40)
@Matches(/^[a-z0-9_-]+$/, { message: "key deve ser minusculo, so letras/numeros/hifen/underscore" })
key!: string;
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsInt()
@Min(0)
maxExtensions?: number;
@IsOptional()
@IsInt()
@Min(0)
maxAgents?: number;
@IsOptional()
@IsInt()
@Min(0)
maxTrunks?: number;
@IsOptional()
@IsInt()
@Min(0)
maxQueues?: number;
@IsOptional()
@IsInt()
@Min(0)
maxCampaigns?: number;
@IsOptional()
@IsInt()
@Min(0)
maxCps?: number;
@IsOptional()
@IsInt()
@Min(0)
maxConcurrentCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxDailyCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxMonthlyCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxRecordingStorageGb?: number;
@IsOptional()
@IsInt()
@Min(0)
recordingRetentionDays?: number;
@IsOptional()
@IsInt()
@Min(0)
transcriptionRetentionDays?: number;
@IsOptional()
@IsBoolean()
recordingEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiTranscriptionEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiAnalysisEnabled?: boolean;
@IsOptional()
@IsBoolean()
apiAccessEnabled?: boolean;
}
export class UpdatePlanDto {
@IsOptional()
@IsString()
@MaxLength(120)
name?: string;
@IsOptional()
@IsInt()
@Min(0)
maxExtensions?: number;
@IsOptional()
@IsInt()
@Min(0)
maxAgents?: number;
@IsOptional()
@IsInt()
@Min(0)
maxTrunks?: number;
@IsOptional()
@IsInt()
@Min(0)
maxQueues?: number;
@IsOptional()
@IsInt()
@Min(0)
maxCampaigns?: number;
@IsOptional()
@IsInt()
@Min(0)
maxCps?: number;
@IsOptional()
@IsInt()
@Min(0)
maxConcurrentCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxDailyCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxMonthlyCalls?: number;
@IsOptional()
@IsInt()
@Min(0)
maxRecordingStorageGb?: number;
@IsOptional()
@IsBoolean()
recordingEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiTranscriptionEnabled?: boolean;
@IsOptional()
@IsBoolean()
aiAnalysisEnabled?: boolean;
@IsOptional()
@IsBoolean()
apiAccessEnabled?: boolean;
}

View File

@@ -0,0 +1,87 @@
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, 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 { CreatePlanDto, UpdatePlanDto } from "./dto/create-plan.dto";
/**
* Catálogo de planos (agente.md secao 56, 126) — global, sem RLS (mesmo
* critério de `plans` desde a PHASE 14: "sem tenant_id, catálogo
* compartilhado"). Só platform admin gerencia; qualquer campo de limite
* `null` significa "sem limite" (nunca "sem plano" — `Tenant.planId` é
* obrigatório desde a migration que fez o backfill).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("plans")
export class PlansController {
@RequirePermission("pricing.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePlanDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode criar planos");
}
const prisma = getPrismaClient();
const existing = await prisma.plan.findUnique({ where: { key: dto.key } });
if (existing) {
throw new ForbiddenException(`Ja existe um plano com key "${dto.key}"`);
}
const plan = await prisma.plan.create({ data: dto });
await recordAuditEvent(prisma, {
action: "PLAN_CREATE",
tenantId: null,
userId: user.sub,
entityType: "plan",
entityId: plan.id,
after: { key: plan.key, name: plan.name },
});
return plan;
}
@RequirePermission("pricing.manage")
@Get()
async list() {
const prisma = getPrismaClient();
return prisma.plan.findMany({ orderBy: { name: "asc" } });
}
@RequirePermission("pricing.manage")
@Get(":id")
async get(@Param("id") id: string) {
const prisma = getPrismaClient();
const plan = await prisma.plan.findUnique({ where: { id } });
if (!plan) throw new NotFoundException();
return plan;
}
@RequirePermission("pricing.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdatePlanDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode editar planos");
}
const prisma = getPrismaClient();
const existing = await prisma.plan.findUnique({ where: { id } });
if (!existing) throw new NotFoundException();
const plan = await prisma.plan.update({ where: { id }, data: dto });
await recordAuditEvent(prisma, {
action: "PLAN_UPDATE",
tenantId: null,
userId: user.sub,
entityType: "plan",
entityId: plan.id,
after: { ...dto },
});
return plan;
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { PlansController } from "./plans.controller";
@Module({
controllers: [PlansController],
})
export class PlansModule {}

View File

@@ -0,0 +1,53 @@
import { IsEmail, IsIn, IsOptional, IsString, IsUUID, Matches, MaxLength } from "class-validator";
const TENANT_STATUSES = ["TRIAL", "ACTIVE", "SUSPENDED", "PAST_DUE", "CANCELLED"];
export class CreateTenantDto {
@IsString()
@MaxLength(40)
@Matches(/^[a-z0-9-]+$/, { message: "code deve ser minusculo, so letras/numeros/hifen (vira tambem o slug)" })
code!: string;
@IsString()
@MaxLength(200)
legalName!: string;
@IsOptional()
@IsString()
@MaxLength(200)
tradeName?: string;
@IsOptional()
@IsString()
@MaxLength(40)
taxId?: string;
@IsUUID()
planId!: string;
/** Cria o primeiro usuário (tenant_admin) na mesma transação — sem essa
* conta o tenant fica inacessível (secao 141: "Tenant Admin é criado
* junto com o tenant"). Senha gerada e devolvida uma única vez, mesmo
* padrão de `SecretReveal` já usado pra senha SIP de ramal. */
@IsEmail()
adminEmail!: string;
@IsString()
@MaxLength(120)
adminName!: string;
}
export class UpdateTenantDto {
@IsOptional()
@IsIn(TENANT_STATUSES)
status?: string;
@IsOptional()
@IsUUID()
planId?: string;
@IsOptional()
@IsString()
@MaxLength(200)
tradeName?: string;
}

View File

@@ -0,0 +1,163 @@
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type TenantStatus } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, hashPassword, type AccessTokenClaims } from "@b2bcall/auth";
import { generateStrongPassword } from "@b2bcall/shared";
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 { CreateTenantDto, UpdateTenantDto } from "./dto/create-tenant.dto";
/**
* CRUD de tenants (agente.md secao 29, 141, 168 "Clientes > Tenants") —
* a peça que faltava desde a PHASE 01: até aqui só existia via script/API
* direto. `tenants` não tem RLS (tabela raiz, ver docs/TENANT_ISOLATION.md),
* então lida direto com `getPrismaClient()`, nunca `withTenantContext`.
*
* `telephonyDomain` fixo em "b2bcall.local" pra todo tenant novo — decisão
* já tomada na PHASE 08 (multi-domínio real por tenant não existe ainda).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("tenants")
export class TenantsController {
@RequirePermission("tenants.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateTenantDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode criar tenants");
}
const prisma = getPrismaClient();
const [existingCode, existingEmail, plan] = await Promise.all([
prisma.tenant.findFirst({ where: { code: dto.code } }),
prisma.user.findUnique({ where: { email: dto.adminEmail } }),
prisma.plan.findUnique({ where: { id: dto.planId } }),
]);
if (existingCode) throw new ForbiddenException(`Ja existe um tenant com code "${dto.code}"`);
if (existingEmail) throw new ForbiddenException(`Ja existe um usuario com o e-mail "${dto.adminEmail}"`);
if (!plan) throw new ForbiddenException("Plano nao encontrado");
const temporaryPassword = generateStrongPassword();
const passwordHash = await hashPassword(temporaryPassword);
const { tenant, admin } = await prisma.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: {
code: dto.code,
slug: dto.code,
legalName: dto.legalName,
tradeName: dto.tradeName,
taxId: dto.taxId,
planId: dto.planId,
telephonyDomain: "b2bcall.local",
},
});
const admin = await tx.user.create({
data: { email: dto.adminEmail, passwordHash, name: dto.adminName, mustChangePassword: true },
});
// RLS de tenant_memberships (secao 32) exige app.current_tenant_id —
// já dentro da mesma transação, sem precisar de withTenantContext
// (que abriria uma transação aninhada em cima de um PrismaClient
// plano, não de um TransactionClient).
await tx.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenant.id}, true)`;
await tx.tenantMembership.create({ data: { tenantId: tenant.id, userId: admin.id } });
const tenantAdminRole = await tx.role.findUniqueOrThrow({ where: { key: "tenant_admin" } });
await tx.userRole.create({ data: { userId: admin.id, roleId: tenantAdminRole.id, tenantId: tenant.id } });
return { tenant, admin };
});
await recordAuditEvent(prisma, {
action: "TENANT_CREATE",
tenantId: tenant.id,
userId: user.sub,
entityType: "tenant",
entityId: tenant.id,
after: { code: tenant.code, legalName: tenant.legalName, planId: tenant.planId, adminEmail: admin.email },
});
return { tenant, admin: { email: admin.email, temporaryPassword } };
}
@RequirePermission("tenants.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode listar tenants");
}
const prisma = getPrismaClient();
const tenants = await prisma.tenant.findMany({
where: { deletedAt: null },
include: { plan: { select: { id: true, key: true, name: true } } },
orderBy: { createdAt: "desc" },
});
// `tenant_memberships` tem FORCE RLS — nem platform admin enxerga
// linha nenhuma numa query sem `app.current_tenant_id` setado (deny-
// by-default, secao 32). Sem uma policy própria pra platform admin
// "ver tudo", a única forma correta é abrir o contexto de cada tenant
// um de cada vez — aceitável aqui, é uma tela de administração, não
// um hot path.
const memberCounts = await Promise.all(
tenants.map((t) => withTenantContext(prisma, t.id, (tx) => tx.tenantMembership.count({ where: { tenantId: t.id } }))),
);
const memberCount = new Map(tenants.map((t, i) => [t.id, memberCounts[i]]));
return tenants.map((t) => ({ ...t, memberCount: memberCount.get(t.id) ?? 0 }));
}
@RequirePermission("tenants.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver detalhe de tenant");
}
const prisma = getPrismaClient();
const tenant = await prisma.tenant.findFirst({
where: { id, deletedAt: null },
include: { plan: true },
});
if (!tenant) throw new NotFoundException();
return tenant;
}
@RequirePermission("tenants.manage")
@Patch(":id")
async update(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: UpdateTenantDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode editar tenants");
}
const prisma = getPrismaClient();
const existing = await prisma.tenant.findFirst({ where: { id, deletedAt: null } });
if (!existing) throw new NotFoundException();
if (dto.planId) {
const plan = await prisma.plan.findUnique({ where: { id: dto.planId } });
if (!plan) throw new ForbiddenException("Plano nao encontrado");
}
const tenant = await prisma.tenant.update({
where: { id },
data: {
...(dto.status ? { status: dto.status as TenantStatus } : {}),
...(dto.planId ? { planId: dto.planId } : {}),
...(dto.tradeName !== undefined ? { tradeName: dto.tradeName } : {}),
},
include: { plan: true },
});
await recordAuditEvent(prisma, {
action: "TENANT_UPDATE",
tenantId: tenant.id,
userId: user.sub,
entityType: "tenant",
entityId: tenant.id,
after: { ...dto },
});
return tenant;
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { TenantsController } from "./tenants.controller";
@Module({
controllers: [TenantsController],
})
export class TenantsModule {}