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:
169
apps/api/src/plans/dto/create-plan.dto.ts
Normal file
169
apps/api/src/plans/dto/create-plan.dto.ts
Normal 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;
|
||||
}
|
||||
87
apps/api/src/plans/plans.controller.ts
Normal file
87
apps/api/src/plans/plans.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
7
apps/api/src/plans/plans.module.ts
Normal file
7
apps/api/src/plans/plans.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PlansController } from "./plans.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [PlansController],
|
||||
})
|
||||
export class PlansModule {}
|
||||
Reference in New Issue
Block a user