diff --git a/TODO.md b/TODO.md index 82fec9a..eb9d6cc 100644 --- a/TODO.md +++ b/TODO.md @@ -1176,6 +1176,61 @@ Troncos, Discador > Lista de Bloqueio (agente.md secao 50-51, 41-42, 71, reconexão do socket.io real antes de fechar o stream, mas não há backoff exponencial nem um teste de queda de rede prolongada +## PHASE 33 — Platform: Clientes > Tenants e Planos (CRUD real, backend +novo) (agente.md secao 29, 56, 126, 141, 168) +- [x] **Lacuna de backend fechada primeiro**: não existia NENHUM endpoint + pra criar/listar/editar tenant nem plano até aqui — só via script/ + seed ad hoc (a própria Acme foi criada assim numa sessão anterior, + sem deixar rastro reproduzível). Dois controllers novos: + `TenantsController` (`/tenants`, `tenants.manage`/`.view`) e + `PlansController` (`/plans`, `pricing.manage`), ambos platform-only + (`isPlatformUser`, mesmo padrão de billing). +- [x] `POST /tenants` cria o tenant **e** o primeiro usuário (Tenant + Admin) numa transação só (secao 141: sem esse usuário o tenant fica + inacessível) — `Tenant.create` + `User.create` + `TenantMembership` + + `UserRole(tenant_admin)`, senha gerada + (`generateStrongPassword`) e devolvida em texto puro só nesta + resposta (mesmo padrão de "revela uma vez" de Ramais/SIP). + `telephonyDomain` fixo em `b2bcall.local` (decisão já tomada na + PHASE 08 — sem multi-domínio real ainda). +- [x] **Bug real, achado testando o próprio endpoint antes de expor no + frontend**: `GET /tenants` calculava `memberCount` com + `tenantMembership.groupBy` numa query sem contexto de tenant + nenhum — `tenant_memberships` tem FORCE RLS (secao 32), então + **nem platform admin** enxerga uma linha sequer sem + `app.current_tenant_id` setado; o campo sempre voltava `0`. + Corrigido abrindo o contexto de RLS de cada tenant um de cada vez + (`Promise.all` de `withTenantContext` por tenant) — aceitável numa + tela de administração, não é hot path. +- [x] Frontend: `/platform/clientes/tenants` (lista com busca, badge de + status próprio — não reusa `StatusBadge`, mesma cautela de gênero + já aplicada em `AgentStateBadge`: "Ativo" de tenant não é "Ativa" + de campanha), `/tenants/new` (cria tenant+admin, revela senha + temporária uma vez), `/tenants/:id` (troca status/plano, mostra os + limites do plano selecionado ao vivo antes de salvar). + `/platform/clientes/planos` (cards com todos os limites, criar e + editar inline — campo de limite em branco = sem limite, nunca + zero). +- [x] Testado ponta a ponta contra a API real: criado plano "Profissional" + (50 ramais/agentes, resto sem limite) → criado tenant "Beta Corp + Telecom LTDA" com admin `admin@beta.b2bcall.local`, senha revelada + uma vez → detalhe do tenant → trocado plano pra "Profissional" → + salvo → confirmado via API que persistiu (`GET /tenants/:id` + retornando o plano novo). Smoke test de regressão nas 19 telas do + tenant + tarifas, todas 200. +- [ ] "Clientes > Assinaturas" e "> Quotas" continuam "em breve" — + `SubscriptionsController`/`PlanVersionsController` já existem + (PHASE 22) mas sem tela; Quotas ficou parcialmente coberta pelos + limites do plano já visíveis no detalhe do tenant, sem uma tela + dedicada de consumo-vs-limite ainda + (esperando **Billing > Consumo**, que depende do mesmo seletor de + tenant) +- [ ] Sem exclusão/soft-delete de tenant nem de plano pela UI (só + suspender via status) — decisão deliberada, mesma cautela de + qualquer ação destrutiva em dado de cliente real +- [ ] `Plan.key` não pode ser editado depois de criado (só os limites) — + não implementado, `key` normalmente não deveria mudar mesmo + --- ## Riscos conhecidos diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f98fccc..416b92d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/plans/dto/create-plan.dto.ts b/apps/api/src/plans/dto/create-plan.dto.ts new file mode 100644 index 0000000..fc7d7f4 --- /dev/null +++ b/apps/api/src/plans/dto/create-plan.dto.ts @@ -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; +} diff --git a/apps/api/src/plans/plans.controller.ts b/apps/api/src/plans/plans.controller.ts new file mode 100644 index 0000000..f595a8f --- /dev/null +++ b/apps/api/src/plans/plans.controller.ts @@ -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; + } +} diff --git a/apps/api/src/plans/plans.module.ts b/apps/api/src/plans/plans.module.ts new file mode 100644 index 0000000..b20fe07 --- /dev/null +++ b/apps/api/src/plans/plans.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { PlansController } from "./plans.controller"; + +@Module({ + controllers: [PlansController], +}) +export class PlansModule {} diff --git a/apps/api/src/tenants/dto/create-tenant.dto.ts b/apps/api/src/tenants/dto/create-tenant.dto.ts new file mode 100644 index 0000000..c9daa48 --- /dev/null +++ b/apps/api/src/tenants/dto/create-tenant.dto.ts @@ -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; +} diff --git a/apps/api/src/tenants/tenants.controller.ts b/apps/api/src/tenants/tenants.controller.ts new file mode 100644 index 0000000..86b6a72 --- /dev/null +++ b/apps/api/src/tenants/tenants.controller.ts @@ -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; + } +} diff --git a/apps/api/src/tenants/tenants.module.ts b/apps/api/src/tenants/tenants.module.ts new file mode 100644 index 0000000..c479e79 --- /dev/null +++ b/apps/api/src/tenants/tenants.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { TenantsController } from "./tenants.controller"; + +@Module({ + controllers: [TenantsController], +}) +export class TenantsModule {} diff --git a/apps/frontend/.impeccable/review/platform-planos-empty-desktop.png b/apps/frontend/.impeccable/review/platform-planos-empty-desktop.png new file mode 100644 index 0000000..75daff0 Binary files /dev/null and b/apps/frontend/.impeccable/review/platform-planos-empty-desktop.png differ diff --git a/apps/frontend/.impeccable/review/platform-planos-with-data-desktop.png b/apps/frontend/.impeccable/review/platform-planos-with-data-desktop.png new file mode 100644 index 0000000..c949969 Binary files /dev/null and b/apps/frontend/.impeccable/review/platform-planos-with-data-desktop.png differ diff --git a/apps/frontend/.impeccable/review/platform-tenant-detail-desktop.png b/apps/frontend/.impeccable/review/platform-tenant-detail-desktop.png new file mode 100644 index 0000000..866c23d Binary files /dev/null and b/apps/frontend/.impeccable/review/platform-tenant-detail-desktop.png differ diff --git a/apps/frontend/.impeccable/review/platform-tenant-detail-updated-desktop.png b/apps/frontend/.impeccable/review/platform-tenant-detail-updated-desktop.png new file mode 100644 index 0000000..866c23d Binary files /dev/null and b/apps/frontend/.impeccable/review/platform-tenant-detail-updated-desktop.png differ diff --git a/apps/frontend/.impeccable/review/platform-tenants-created-desktop.png b/apps/frontend/.impeccable/review/platform-tenants-created-desktop.png new file mode 100644 index 0000000..f6c9ea9 Binary files /dev/null and b/apps/frontend/.impeccable/review/platform-tenants-created-desktop.png differ diff --git a/apps/frontend/.impeccable/review/platform-tenants-list-desktop.png b/apps/frontend/.impeccable/review/platform-tenants-list-desktop.png new file mode 100644 index 0000000..2d12496 Binary files /dev/null and b/apps/frontend/.impeccable/review/platform-tenants-list-desktop.png differ diff --git a/apps/frontend/.impeccable/review/platform-tenants-new-desktop.png b/apps/frontend/.impeccable/review/platform-tenants-new-desktop.png new file mode 100644 index 0000000..e2ddcf3 Binary files /dev/null and b/apps/frontend/.impeccable/review/platform-tenants-new-desktop.png differ diff --git a/apps/frontend/src/app/platform/clientes/planos/actions.ts b/apps/frontend/src/app/platform/clientes/planos/actions.ts new file mode 100644 index 0000000..8e292db --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/planos/actions.ts @@ -0,0 +1,70 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { Plan } from "@/lib/platform-types"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export type PlanLimitsInput = Partial< + Pick< + Plan, + | "maxExtensions" + | "maxAgents" + | "maxTrunks" + | "maxQueues" + | "maxCampaigns" + | "maxCps" + | "maxConcurrentCalls" + | "maxDailyCalls" + | "maxMonthlyCalls" + | "maxRecordingStorageGb" + | "recordingEnabled" + | "aiEnabled" + | "aiTranscriptionEnabled" + | "aiAnalysisEnabled" + > +>; + +export interface CreatePlanInput extends PlanLimitsInput { + key: string; + name: string; +} + +export async function createPlan(input: CreatePlanInput): Promise<{ ok: true; plan: Plan } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const plan = await apiFetch("/plans", session.accessToken, { method: "POST", body: JSON.stringify(input) }); + revalidatePath("/platform/clientes/planos"); + return { ok: true, plan }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export async function updatePlan( + id: string, + input: PlanLimitsInput & { name?: string }, +): Promise<{ ok: true; plan: Plan } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const plan = await apiFetch(`/plans/${id}`, session.accessToken, { method: "PATCH", body: JSON.stringify(input) }); + revalidatePath("/platform/clientes/planos"); + return { ok: true, plan }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/platform/clientes/planos/page.tsx b/apps/frontend/src/app/platform/clientes/planos/page.tsx new file mode 100644 index 0000000..e91fc6b --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/planos/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { Plan } from "@/lib/platform-types"; +import { PlanosView } from "./planos-view"; + +export default async function PlanosPage() { + const session = await requireSession(); + const plans = await apiFetch("/plans", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/clientes/planos/planos-view.tsx b/apps/frontend/src/app/platform/clientes/planos/planos-view.tsx new file mode 100644 index 0000000..2bece85 --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/planos/planos-view.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { CreditCard, Plus, X } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, FieldLabel } from "@/components/ui/input"; +import { Pill } from "@/components/ui/pill"; +import { EmptyState } from "@/components/ui/table"; +import { PLAN_LIMIT_FIELDS, type Plan } from "@/lib/platform-types"; +import { createPlan, updatePlan, type PlanLimitsInput } from "./actions"; + +type LimitsState = Record; + +function limitsToState(plan?: Plan): LimitsState { + const state: LimitsState = {}; + for (const { key } of PLAN_LIMIT_FIELDS) { + state[key] = plan?.[key] == null ? "" : String(plan[key]); + } + return state; +} + +function limitsToInput(state: LimitsState): PlanLimitsInput { + const input: PlanLimitsInput = {}; + for (const { key } of PLAN_LIMIT_FIELDS) { + const raw = state[key]; + (input as Record)[key] = raw.trim() === "" ? undefined : Number(raw); + } + return input; +} + +export function PlanosView({ plans }: { plans: Plan[] }) { + const [showForm, setShowForm] = useState(false); + + return ( +
+
+
+

Planos

+

+ Catálogo de planos (agente.md secao 56, 126) — campo de limite em branco significa{" "} + sem limite, nunca zero. +

+
+ +
+ + {showForm && setShowForm(false)} />} + + {plans.length === 0 ? ( + + + + ) : ( +
+ {plans.map((plan) => ( + + ))} +
+ )} +
+ ); +} + +function LimitFields({ state, onChange, disabled }: { state: LimitsState; onChange: (key: string, value: string) => void; disabled: boolean }) { + return ( +
+ {PLAN_LIMIT_FIELDS.map(({ key, label }) => ( +
+ {label} + onChange(key, e.target.value)} + placeholder="sem limite" + disabled={disabled} + /> +
+ ))} +
+ ); +} + +function FlagToggle({ label, checked, onChange, disabled }: { label: string; checked: boolean; onChange: (v: boolean) => void; disabled: boolean }) { + return ( + + ); +} + +function NewPlanForm({ onDone }: { onDone: () => void }) { + const [key, setKey] = useState(""); + const [name, setName] = useState(""); + const [limits, setLimits] = useState(() => limitsToState()); + const [recordingEnabled, setRecordingEnabled] = useState(true); + const [aiEnabled, setAiEnabled] = useState(false); + const [aiTranscriptionEnabled, setAiTranscriptionEnabled] = useState(false); + const [aiAnalysisEnabled, setAiAnalysisEnabled] = useState(false); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + if (!/^[a-z0-9_-]+$/.test(key) || !name.trim()) { + setError("Key (minúsculo) e nome são obrigatórios."); + return; + } + startTransition(async () => { + const result = await createPlan({ + key, + name: name.trim(), + ...limitsToInput(limits), + recordingEnabled, + aiEnabled, + aiTranscriptionEnabled, + aiAnalysisEnabled, + }); + if (!result.ok) { + setError(result.error); + return; + } + onDone(); + }); + } + + return ( + +
+
+
+ Key + setKey(e.target.value.toLowerCase())} placeholder="ex.: pro" className="font-mono" disabled={pending} /> +
+
+ Nome + setName(e.target.value)} placeholder="Ex.: Profissional" disabled={pending} /> +
+
+ + setLimits((prev) => ({ ...prev, [k]: v }))} disabled={pending} /> + +
+ + + + +
+ + {error && ( +

+ {error} +

+ )} +
+ +
+ +
+ ); +} + +function PlanCard({ plan }: { plan: Plan }) { + const router = useRouter(); + const [editing, setEditing] = useState(false); + const [limits, setLimits] = useState(() => limitsToState(plan)); + const [recordingEnabled, setRecordingEnabled] = useState(plan.recordingEnabled); + const [aiEnabled, setAiEnabled] = useState(plan.aiEnabled); + const [aiTranscriptionEnabled, setAiTranscriptionEnabled] = useState(plan.aiTranscriptionEnabled); + const [aiAnalysisEnabled, setAiAnalysisEnabled] = useState(plan.aiAnalysisEnabled); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + function onSave() { + setError(null); + startTransition(async () => { + const result = await updatePlan(plan.id, { + ...limitsToInput(limits), + recordingEnabled, + aiEnabled, + aiTranscriptionEnabled, + aiAnalysisEnabled, + }); + if (!result.ok) { + setError(result.error); + return; + } + setEditing(false); + router.refresh(); + }); + } + + return ( + +
+
+ +

{plan.name}

+ {plan.key} +
+ {!editing && ( + + )} +
+
+ {editing ? ( +
+ setLimits((prev) => ({ ...prev, [k]: v }))} disabled={pending} /> +
+ + + + +
+ {error && ( +

+ {error} +

+ )} +
+ + +
+
+ ) : ( +
    + {PLAN_LIMIT_FIELDS.map(({ key, label }) => ( +
  • + {label} + {plan[key] == null ? "sem limite" : String(plan[key])} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/app/platform/clientes/tenants/[id]/page.tsx b/apps/frontend/src/app/platform/clientes/tenants/[id]/page.tsx new file mode 100644 index 0000000..479d90a --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/tenants/[id]/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { Plan, Tenant } from "@/lib/platform-types"; +import { TenantDetailView } from "./tenant-detail-view"; + +export default async function TenantDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const session = await requireSession(); + + try { + const [tenant, plans] = await Promise.all([ + apiFetch(`/tenants/${id}`, session.accessToken), + apiFetch("/plans", session.accessToken), + ]); + return ; + } catch (err) { + if (err instanceof ApiError && err.status === 404) notFound(); + throw err; + } +} diff --git a/apps/frontend/src/app/platform/clientes/tenants/[id]/tenant-detail-view.tsx b/apps/frontend/src/app/platform/clientes/tenants/[id]/tenant-detail-view.tsx new file mode 100644 index 0000000..20fc8be --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/tenants/[id]/tenant-detail-view.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Building2 } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Select, FieldLabel } from "@/components/ui/input"; +import { Pill } from "@/components/ui/pill"; +import { formatDate } from "@/lib/format"; +import { PLAN_LIMIT_FIELDS, TENANT_STATUS_LABELS, type Plan, type Tenant } from "@/lib/platform-types"; +import { updateTenant } from "../actions"; + +const STATUSES = ["TRIAL", "ACTIVE", "SUSPENDED", "PAST_DUE", "CANCELLED"]; + +export function TenantDetailView({ tenant, plans }: { tenant: Tenant & { plan: Plan }; plans: Plan[] }) { + const router = useRouter(); + const [status, setStatus] = useState(tenant.status); + const [planId, setPlanId] = useState(tenant.planId); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + const dirty = status !== tenant.status || planId !== tenant.planId; + + function onSave() { + setError(null); + setSaved(false); + startTransition(async () => { + const result = await updateTenant(tenant.id, { status, planId }); + if (!result.ok) { + setError(result.error); + return; + } + setSaved(true); + router.refresh(); + }); + } + + const selectedPlan = plans.find((p) => p.id === planId) ?? tenant.plan; + + return ( +
+
+

+ + {tenant.legalName} +

+

+ Código {tenant.code} — criado em {formatDate(tenant.createdAt)},{" "} + {tenant.memberCount} usuário(s) +

+
+ + +

Status e plano

+
+
+ Status + +
+
+ Plano + +
+
+ {error && ( +

+ {error} +

+ )} + {saved && !dirty &&

Salvo.

} +
+ +
+
+ + + +
    + {PLAN_LIMIT_FIELDS.map(({ key, label }) => ( +
  • + {label} + + {selectedPlan[key] == null ? "sem limite" : String(selectedPlan[key])} + +
  • + ))} +
  • + Gravação habilitada + {selectedPlan.recordingEnabled ? "Sim" : "Não"} +
  • +
  • + IA habilitada + {selectedPlan.aiEnabled ? "Sim" : "Não"} +
  • +
+
+
+ ); +} diff --git a/apps/frontend/src/app/platform/clientes/tenants/actions.ts b/apps/frontend/src/app/platform/clientes/tenants/actions.ts new file mode 100644 index 0000000..c52118f --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/tenants/actions.ts @@ -0,0 +1,69 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSession } from "@/lib/session"; +import { apiFetch, ApiError } from "@/lib/api"; +import type { Tenant } from "@/lib/platform-types"; + +function extractErrorMessage(err: unknown): string { + if (err instanceof ApiError) { + try { + const parsed = JSON.parse(err.message); + if (Array.isArray(parsed.message)) return parsed.message.join(" "); + if (typeof parsed.message === "string") return parsed.message; + } catch { + // corpo não era JSON + } + return err.message || "Falha inesperada na API."; + } + return "Falha inesperada. Tente novamente."; +} + +export interface CreateTenantInput { + code: string; + legalName: string; + tradeName?: string; + taxId?: string; + planId: string; + adminEmail: string; + adminName: string; +} + +export interface CreateTenantResult { + tenant: Tenant; + admin: { email: string; temporaryPassword: string }; +} + +export async function createTenant(input: CreateTenantInput): Promise<({ ok: true } & CreateTenantResult) | { ok: false; error: string }> { + const session = await requireSession(); + try { + const result = await apiFetch("/tenants", session.accessToken, { + method: "POST", + body: JSON.stringify(input), + }); + revalidatePath("/platform/clientes/tenants"); + return { ok: true, ...result }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} + +export interface UpdateTenantInput { + status?: string; + planId?: string; +} + +export async function updateTenant(id: string, input: UpdateTenantInput): Promise<{ ok: true; tenant: Tenant } | { ok: false; error: string }> { + const session = await requireSession(); + try { + const tenant = await apiFetch(`/tenants/${id}`, session.accessToken, { + method: "PATCH", + body: JSON.stringify(input), + }); + revalidatePath("/platform/clientes/tenants"); + revalidatePath(`/platform/clientes/tenants/${id}`); + return { ok: true, tenant }; + } catch (err) { + return { ok: false, error: extractErrorMessage(err) }; + } +} diff --git a/apps/frontend/src/app/platform/clientes/tenants/new/form.tsx b/apps/frontend/src/app/platform/clientes/tenants/new/form.tsx new file mode 100644 index 0000000..8aa2c7b --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/tenants/new/form.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useState, useTransition } from "react"; +import Link from "next/link"; +import { Check } from "lucide-react"; +import { Panel } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input, Select, FieldLabel } from "@/components/ui/input"; +import { SecretReveal } from "@/components/ui/secret-reveal"; +import type { Plan } from "@/lib/platform-types"; +import { createTenant, type CreateTenantResult } from "../actions"; + +export function NewTenantForm({ plans }: { plans: Plan[] }) { + const [code, setCode] = useState(""); + const [legalName, setLegalName] = useState(""); + const [tradeName, setTradeName] = useState(""); + const [taxId, setTaxId] = useState(""); + const [planId, setPlanId] = useState(plans[0]?.id ?? ""); + const [adminEmail, setAdminEmail] = useState(""); + const [adminName, setAdminName] = useState(""); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + const [created, setCreated] = useState(null); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (!/^[a-z0-9-]+$/.test(code)) { + setError("Código deve ser minúsculo, só letras/números/hífen."); + return; + } + if (!legalName.trim() || !adminEmail.trim() || !adminName.trim() || !planId) { + setError("Razão social, plano, e-mail e nome do admin são obrigatórios."); + return; + } + + startTransition(async () => { + const result = await createTenant({ + code, + legalName: legalName.trim(), + tradeName: tradeName.trim() || undefined, + taxId: taxId.trim() || undefined, + planId, + adminEmail: adminEmail.trim(), + adminName: adminName.trim(), + }); + if (!result.ok) { + setError(result.error); + return; + } + setCreated(result); + }); + } + + if (created) { + return ( +
+ + + + +
+

Tenant {created.tenant.legalName} criado

+

+ Admin: {created.admin.email} (troca de senha obrigatória no + primeiro login) +

+
+
+ + + +
+ + +
+
+ ); + } + + if (plans.length === 0) { + return ( + +

+ Nenhum plano cadastrado ainda. Crie um plano antes de criar um tenant. +

+
+ ); + } + + return ( +
+ +
+
+ Código (identificador único) + setCode(e.target.value.toLowerCase())} + placeholder="ex.: acme" + className="font-mono" + disabled={pending} + /> +
+
+ Plano + +
+
+
+
+ Razão social + setLegalName(e.target.value)} placeholder="Ex.: Acme Call Center LTDA" disabled={pending} /> +
+
+ Nome fantasia (opcional) + setTradeName(e.target.value)} placeholder="Ex.: Acme Call Center" disabled={pending} /> +
+
+
+ CNPJ/CPF (opcional) + setTaxId(e.target.value)} className="font-mono" disabled={pending} /> +
+
+ + +

Primeiro usuário (Tenant Admin)

+
+
+ Nome + setAdminName(e.target.value)} disabled={pending} /> +
+
+ E-mail + setAdminEmail(e.target.value)} disabled={pending} /> +
+
+
+ + {error && ( +

+ {error} +

+ )} + +
+ +
+
+ ); +} diff --git a/apps/frontend/src/app/platform/clientes/tenants/new/page.tsx b/apps/frontend/src/app/platform/clientes/tenants/new/page.tsx new file mode 100644 index 0000000..1a10fe0 --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/tenants/new/page.tsx @@ -0,0 +1,21 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { Plan } from "@/lib/platform-types"; +import { NewTenantForm } from "./form"; + +export default async function NewTenantPage() { + const session = await requireSession(); + const plans = await apiFetch("/plans", session.accessToken); + + return ( +
+
+

Novo tenant

+

+ Cria o cliente e o primeiro usuário (Tenant Admin) na mesma ação. +

+
+ +
+ ); +} diff --git a/apps/frontend/src/app/platform/clientes/tenants/page.tsx b/apps/frontend/src/app/platform/clientes/tenants/page.tsx new file mode 100644 index 0000000..33be457 --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/tenants/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { Tenant } from "@/lib/platform-types"; +import { TenantsView } from "./tenants-view"; + +export default async function TenantsPage() { + const session = await requireSession(); + const tenants = await apiFetch("/tenants", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/clientes/tenants/tenants-view.tsx b/apps/frontend/src/app/platform/clientes/tenants/tenants-view.tsx new file mode 100644 index 0000000..5249d2a --- /dev/null +++ b/apps/frontend/src/app/platform/clientes/tenants/tenants-view.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { Building2, Plus, Search } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Pill } from "@/components/ui/pill"; +import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table"; +import { formatDate } from "@/lib/format"; +import { TENANT_STATUS_LABELS, type Tenant } from "@/lib/platform-types"; + +const STATUS_TONE: Record = { + ACTIVE: "accent", + TRIAL: "neutral", + SUSPENDED: "neutral", + PAST_DUE: "neutral", + CANCELLED: "neutral", +}; + +export function TenantsView({ tenants }: { tenants: Tenant[] }) { + const [query, setQuery] = useState(""); + + const rows = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return tenants; + return tenants.filter((t) => t.legalName.toLowerCase().includes(q) || t.code.toLowerCase().includes(q)); + }, [tenants, query]); + + return ( +
+
+
+

Tenants

+

+ Clientes da plataforma (agente.md secao 29, 141) — criar um tenant já cria o primeiro usuário + (Tenant Admin) na mesma ação, sem esse usuário o tenant fica inacessível. +

+
+ +
+ + + +
+
+ + setQuery(e.target.value)} placeholder="Buscar tenant…" className="pl-8" aria-label="Buscar tenant" /> +
+
+ {rows.length === 0 ? ( + 0 ? "Nenhum tenant bate com essa busca" : "Nenhum tenant cadastrado ainda"} + description={tenants.length > 0 ? "Tente outro termo." : "Crie o primeiro cliente da plataforma."} + /> + ) : ( + + + + + + + + + + + + + {rows.map((t) => ( + + + + + + + + + ))} + +
TenantCódigoPlanoStatusUsuáriosCriado
+ + + {t.legalName} + + {t.code}{t.plan.name} + {TENANT_STATUS_LABELS[t.status] ?? t.status} + {t.memberCount}{formatDate(t.createdAt)}
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/components/platform-shell/nav-data.ts b/apps/frontend/src/components/platform-shell/nav-data.ts index 3ec5ce3..3984009 100644 --- a/apps/frontend/src/components/platform-shell/nav-data.ts +++ b/apps/frontend/src/components/platform-shell/nav-data.ts @@ -19,7 +19,20 @@ export const PLATFORM_NAV: NavSection[] = [ { label: "Clientes", icon: Building2, - children: [{ label: "Tenants" }, { label: "Planos" }, { label: "Assinaturas" }, { label: "Quotas" }], + children: [ + { + label: "Tenants", + href: "/platform/clientes/tenants", + description: "Clientes da plataforma — criar cria também o primeiro usuário", + }, + { + label: "Planos", + href: "/platform/clientes/planos", + description: "Catálogo de limites e recursos por plano", + }, + { label: "Assinaturas" }, + { label: "Quotas" }, + ], }, { label: "Billing", diff --git a/apps/frontend/src/lib/platform-types.ts b/apps/frontend/src/lib/platform-types.ts new file mode 100644 index 0000000..ad7e035 --- /dev/null +++ b/apps/frontend/src/lib/platform-types.ts @@ -0,0 +1,61 @@ +export interface Plan { + id: string; + key: string; + name: string; + maxExtensions: number | null; + maxAgents: number | null; + maxTrunks: number | null; + maxQueues: number | null; + maxCampaigns: number | null; + maxCps: number | null; + maxConcurrentCalls: number | null; + maxDailyCalls: number | null; + maxMonthlyCalls: number | null; + maxRecordingStorageGb: number | null; + recordingRetentionDays: number | null; + transcriptionRetentionDays: number | null; + recordingEnabled: boolean; + aiEnabled: boolean; + aiTranscriptionEnabled: boolean; + aiAnalysisEnabled: boolean; + apiAccessEnabled: boolean; + createdAt: string; +} + +export interface Tenant { + id: string; + code: string; + slug: string; + legalName: string; + tradeName: string | null; + taxId: string | null; + status: "TRIAL" | "ACTIVE" | "SUSPENDED" | "PAST_DUE" | "CANCELLED"; + timezone: string; + locale: string; + billingCurrency: string; + planId: string; + plan: { id: string; key: string; name: string }; + memberCount: number; + createdAt: string; +} + +export const TENANT_STATUS_LABELS: Record = { + TRIAL: "Trial", + ACTIVE: "Ativo", + SUSPENDED: "Suspenso", + PAST_DUE: "Inadimplente", + CANCELLED: "Cancelado", +}; + +export const PLAN_LIMIT_FIELDS: { key: keyof Plan; label: string }[] = [ + { key: "maxExtensions", label: "Ramais" }, + { key: "maxAgents", label: "Agentes" }, + { key: "maxTrunks", label: "Troncos" }, + { key: "maxQueues", label: "Filas" }, + { key: "maxCampaigns", label: "Campanhas" }, + { key: "maxCps", label: "CPS" }, + { key: "maxConcurrentCalls", label: "Chamadas simultâneas" }, + { key: "maxDailyCalls", label: "Chamadas/dia" }, + { key: "maxMonthlyCalls", label: "Chamadas/mês" }, + { key: "maxRecordingStorageGb", label: "Armazenamento (GB)" }, +];