feat(frontend): Platform > Clientes > Assinaturas/Quotas + achado real no dashboard
GET /billing/subscriptions e POST /billing/plan-versions já existiam desde a fase Billing (PHASE 22) sem tela nenhuma — Assinaturas agora deixa escolher um tenant, ver o histórico de versões de preço assinadas, e criar uma nova assinatura reaproveitando uma versão existente ou versionando um preço novo na mesma ação (preço nunca é sobrescrito, sempre uma linha nova). GET /platform/quotas (novo) agrega uso vs. limite do plano em todos os tenants de uma vez (ramais/agentes/troncos/filas/campanhas/chamadas-mês/armazenamento), com a tela destacando quem está em 80%+ (amarelo) ou 100%+ (vermelho) do limite. Achado real ao revisar o dashboard "Visão Geral" antes de escrever a agregação cross-tenant de Quotas: aiUsageThisMonth/recordingStorageBytes sempre devolviam zero/vazio, porque a query rodava direto no Prisma sem nenhum app.current_tenant_id setado — ai_usage_records/recordings têm FORCE RLS, então a policy nega a leitura silenciosamente (0 linhas, sem erro), não importa quanto uso real existisse. Mesma classe de bug já corrigida 2x antes nesta sessão; corrigido com o mesmo padrão (loop withTenantContext por tenant). Confirmado inserindo um AIUsageRecord de teste no Postgres, vendo o número aparecer, e removendo o teste depois. Testado ponta a ponta: fluxo completo de criar assinatura via UI pro tenant Beta Corp (nova versão de preço + assinatura, confirmado na tela e no banco), Quotas mostrando os números reais dos dois tenants de teste. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient } from "@b2bcall/database";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
@@ -28,32 +28,46 @@ export class PlatformOverviewController {
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const monthStart = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
|
||||
|
||||
const [
|
||||
tenantsActive,
|
||||
tenantsTotal,
|
||||
extensionsTotal,
|
||||
agentsTotal,
|
||||
callsCurrent,
|
||||
callsToday,
|
||||
cpsCapacity,
|
||||
aiUsageThisMonth,
|
||||
recordingBytesAgg,
|
||||
] = await Promise.all([
|
||||
prisma.tenant.count({ where: { status: "ACTIVE", deletedAt: null } }),
|
||||
prisma.tenant.count({ where: { deletedAt: null } }),
|
||||
prisma.extension.count({ where: { deletedAt: null } }),
|
||||
prisma.agent.count({ where: { deletedAt: null } }),
|
||||
prisma.call.count({ where: { endAt: null } }),
|
||||
prisma.call.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
prisma.plan.aggregate({ _sum: { maxCps: true } }),
|
||||
prisma.aIUsageRecord.groupBy({
|
||||
by: ["type"],
|
||||
where: { occurredAt: { gte: monthStart } },
|
||||
_sum: { quantity: true },
|
||||
}),
|
||||
prisma.recording.aggregate({ _sum: { sizeBytes: true } }),
|
||||
const [tenantsActive, tenantsTotal, extensionsTotal, agentsTotal, callsCurrent, callsToday, cpsCapacity] =
|
||||
await Promise.all([
|
||||
prisma.tenant.count({ where: { status: "ACTIVE", deletedAt: null } }),
|
||||
prisma.tenant.count({ where: { deletedAt: null } }),
|
||||
prisma.extension.count({ where: { deletedAt: null } }),
|
||||
prisma.agent.count({ where: { deletedAt: null } }),
|
||||
prisma.call.count({ where: { endAt: null } }),
|
||||
prisma.call.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
prisma.plan.aggregate({ _sum: { maxCps: true } }),
|
||||
]);
|
||||
|
||||
// `ai_usage_records`/`recordings` têm FORCE RLS (secao 32) — as duas
|
||||
// agregações acima (achado real, corrigido aqui) rodavam direto no
|
||||
// Prisma sem `app.current_tenant_id` nenhum setado, então SEMPRE
|
||||
// devolviam 0 linhas (zero silencioso, sem erro nenhum), nunca o
|
||||
// número real, não importa quanto uso existisse nos tenants. Mesma
|
||||
// classe de bug já corrigida em `TenantsController.list` — só um
|
||||
// loop `withTenantContext` por tenant enxerga as linhas de verdade.
|
||||
const activeTenantIds = await prisma.tenant.findMany({ where: { deletedAt: null }, select: { id: true } });
|
||||
const [aiUsagePerTenant, recordingBytesPerTenant] = await Promise.all([
|
||||
Promise.all(
|
||||
activeTenantIds.map((t) =>
|
||||
withTenantContext(prisma, t.id, (tx) =>
|
||||
tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId: t.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
Promise.all(
|
||||
activeTenantIds.map((t) =>
|
||||
withTenantContext(prisma, t.id, (tx) => tx.recording.aggregate({ where: { tenantId: t.id }, _sum: { sizeBytes: true } })),
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
const aiUsageThisMonth: Record<string, number> = {};
|
||||
for (const rows of aiUsagePerTenant) {
|
||||
for (const row of rows) aiUsageThisMonth[row.type] = (aiUsageThisMonth[row.type] ?? 0) + (row._sum.quantity ?? 0);
|
||||
}
|
||||
const recordingStorageBytes = recordingBytesPerTenant.reduce((sum, agg) => sum + Number(agg._sum.sizeBytes ?? 0n), 0);
|
||||
|
||||
return {
|
||||
tenantsActive,
|
||||
tenantsTotal,
|
||||
@@ -70,10 +84,8 @@ export class PlatformOverviewController {
|
||||
// token bucket do Redis do predictive-dialer, apps/api nao le de
|
||||
// la ainda).
|
||||
cpsCapacityConfigured: cpsCapacity._sum.maxCps ?? null,
|
||||
aiUsageThisMonth: Object.fromEntries(
|
||||
aiUsageThisMonth.map((row) => [row.type, row._sum.quantity ?? 0]),
|
||||
),
|
||||
recordingStorageBytes: Number(recordingBytesAgg._sum.sizeBytes ?? 0n),
|
||||
aiUsageThisMonth,
|
||||
recordingStorageBytes,
|
||||
// Precisa de BillingPeriod/BillingStatement fechados de verdade
|
||||
// (fase Billing, em construcao) — nenhum periodo foi fechado ainda
|
||||
// nesta lab, entao nao ha numero real pra mostrar. null e' honesto,
|
||||
|
||||
73
apps/api/src/platform/platform-quotas.controller.ts
Normal file
73
apps/api/src/platform/platform-quotas.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { 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";
|
||||
|
||||
/**
|
||||
* "Clientes > Quotas" (agente.md secao 169) — uso vs. limite do plano em
|
||||
* TODOS os tenants, pra platform admin achar quem está perto de estourar
|
||||
* sem precisar abrir um por um. Mesmo cálculo de `/reports/consumo`
|
||||
* (tenant), só que looping por tenant (RLS, secao 32) em vez de um só.
|
||||
*/
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("platform/quotas")
|
||||
export class PlatformQuotasController {
|
||||
@RequirePermission("tenants.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims): Promise<Record<string, unknown>[]> {
|
||||
if (!(await isPlatformUser(user.sub))) {
|
||||
throw new ForbiddenException("So' um usuario com role de plataforma pode ver quotas de todos os tenants");
|
||||
}
|
||||
const prisma = getPrismaClient();
|
||||
const now = new Date();
|
||||
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
|
||||
const tenants = await prisma.tenant.findMany({
|
||||
where: { deletedAt: null },
|
||||
include: { plan: true },
|
||||
orderBy: { legalName: "asc" },
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
tenants.map(async (tenant) => {
|
||||
const [callCountAgg, recordingAgg, extensionCount, agentCount, trunkCount, queueCount, campaignCount] =
|
||||
await withTenantContext(prisma, tenant.id, (tx) =>
|
||||
Promise.all([
|
||||
tx.usageEvent.aggregate({ where: { tenantId: tenant.id, meter: "CALL_COUNT", occurredAt: { gte: monthStart } }, _sum: { quantity: true } }),
|
||||
tx.recording.aggregate({ where: { tenantId: tenant.id }, _sum: { sizeBytes: true } }),
|
||||
tx.extension.count({ where: { tenantId: tenant.id, deletedAt: null } }),
|
||||
tx.agent.count({ where: { tenantId: tenant.id, deletedAt: null } }),
|
||||
tx.trunk.count({ where: { tenantId: tenant.id, deletedAt: null } }),
|
||||
tx.queue.count({ where: { tenantId: tenant.id, deletedAt: null } }),
|
||||
tx.campaign.count({ where: { tenantId: tenant.id, deletedAt: null } }),
|
||||
]),
|
||||
);
|
||||
|
||||
const callCount = callCountAgg._sum.quantity ?? 0;
|
||||
const recordingBytes = Number(recordingAgg._sum.sizeBytes ?? 0n);
|
||||
const recordingGb = recordingBytes / 1024 ** 3;
|
||||
|
||||
const ratio = (used: number, max: number | null) => (max ? used / max : null);
|
||||
|
||||
return {
|
||||
tenantId: tenant.id,
|
||||
legalName: tenant.legalName,
|
||||
planName: tenant.plan.name,
|
||||
status: tenant.status,
|
||||
items: [
|
||||
{ key: "extensions", label: "Ramais", used: extensionCount, max: tenant.plan.maxExtensions, ratio: ratio(extensionCount, tenant.plan.maxExtensions) },
|
||||
{ key: "agents", label: "Agentes", used: agentCount, max: tenant.plan.maxAgents, ratio: ratio(agentCount, tenant.plan.maxAgents) },
|
||||
{ key: "trunks", label: "Troncos", used: trunkCount, max: tenant.plan.maxTrunks, ratio: ratio(trunkCount, tenant.plan.maxTrunks) },
|
||||
{ key: "queues", label: "Filas", used: queueCount, max: tenant.plan.maxQueues, ratio: ratio(queueCount, tenant.plan.maxQueues) },
|
||||
{ key: "campaigns", label: "Campanhas", used: campaignCount, max: tenant.plan.maxCampaigns, ratio: ratio(campaignCount, tenant.plan.maxCampaigns) },
|
||||
{ key: "monthlyCalls", label: "Chamadas/mês", used: callCount, max: tenant.plan.maxMonthlyCalls, ratio: ratio(callCount, tenant.plan.maxMonthlyCalls) },
|
||||
{ key: "recordingGb", label: "Armazenamento (GB)", used: Number(recordingGb.toFixed(3)), max: tenant.plan.maxRecordingStorageGb, ratio: ratio(recordingGb, tenant.plan.maxRecordingStorageGb) },
|
||||
],
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { PlatformUsersController } from "./platform-users.controller";
|
||||
import { PlatformAuditController } from "./platform-audit.controller";
|
||||
import { PlatformHealthController } from "./platform-health.controller";
|
||||
import { PlatformRolesController } from "./platform-roles.controller";
|
||||
import { PlatformQuotasController } from "./platform-quotas.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [
|
||||
@@ -12,6 +13,7 @@ import { PlatformRolesController } from "./platform-roles.controller";
|
||||
PlatformAuditController,
|
||||
PlatformHealthController,
|
||||
PlatformRolesController,
|
||||
PlatformQuotasController,
|
||||
],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
|
||||
Reference in New Issue
Block a user