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