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,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<string, string> = {
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)" },
];