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

@@ -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 {}