diff --git a/TODO.md b/TODO.md index b5eebcc..647527f 100644 --- a/TODO.md +++ b/TODO.md @@ -1657,6 +1657,33 @@ secao 96-103, 124, 169) + achado real de autorização em `/ai/models` manual) e a tela de Custos mostrando o mesmo número — tudo removido no final +## PHASE 46 — Platform > Billing > Consumo + Sistema > Configurações +(fecha a lista inteira de "em breve" do menu Platform, agente.md secao +169) +- [x] `GET /billing/consumo` — mesma agregação de `/reports/consumo` + (tenant), só que em loop por todos os tenants (`withTenantContext` + por tenant, mesmo padrão já usado em Quotas/ai-usage). Nunca + dinheiro, só quantidade bruta — dinheiro é Billing > Relatórios + (`BillingStatement`, já existia) +- [x] `GET /platform/system-config` — Sistema > Configurações nunca teve + escopo definido na especificação; decisão desta implementação: + painel **somente leitura** das flags de segurança/infra que já + existem como variável de ambiente (`DIALER_SIMULATION`/ + `ALLOW_REAL_OUTBOUND_CALLS`, secao 186; ESL configurado; storage + provider; NODE_ENV), nunca editável por aqui — mudar exige editar + o `.env` e reiniciar o serviço. Nunca expõe segredo nenhum (senha, + chave, connection string), só o que já é público conhecimento de + quem administra a infra +- [x] Testado ponta a ponta: `/billing/consumo` batendo com os mesmos + números já vistos em Relatórios > Consumo/Quotas (Acme com 2 + dias-tronco), `/platform/system-config` confirmado mostrando o + valor real do `.env` (`DIALER_SIMULATION=true`, + `ALLOW_REAL_OUTBOUND_CALLS=false`, ESL configurado) +- [x] Com isto, **todo item do menu Platform tem tela real** — zero + `{ label: "X" }` sem `href` restando em `platform-shell/nav-data.ts` + (confirmado por grep). O menu Tenant já tinha zerado essa lista na + PHASE 42 + --- ## Riscos conhecidos diff --git a/apps/api/src/billing/billing-consumo.controller.ts b/apps/api/src/billing/billing-consumo.controller.ts new file mode 100644 index 0000000..a5373cd --- /dev/null +++ b/apps/api/src/billing/billing-consumo.controller.ts @@ -0,0 +1,67 @@ +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"; + +/** + * "Billing > Consumo" (agente.md secao 169) — a mesma agregação de + * `/reports/consumo` (tenant, mês corrente), só que em TODOS os tenants + * de uma vez. Nunca calcula valor em dinheiro (isso é o RatingEngine/ + * BillingStatement — Billing > Relatórios), só a quantidade bruta dos 2 + * ledgers imutáveis. Diferente de Clientes > Quotas: aqui é "quanto cada + * tenant consumiu", lá é "quanto sobra até o limite do plano". + */ +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("billing/consumo") +export class BillingConsumoController { + @RequirePermission("billing.view") + @Get() + async list(@CurrentUser() user: AccessTokenClaims): Promise[]> { + if (!(await isPlatformUser(user.sub))) { + throw new ForbiddenException("So' um usuario com role de plataforma pode ver consumo 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 }, orderBy: { legalName: "asc" } }); + + return Promise.all( + tenants.map(async (tenant) => { + const [usageByMeter, aiUsageByType] = await withTenantContext(prisma, tenant.id, (tx) => + Promise.all([ + tx.usageEvent.groupBy({ by: ["meter"], where: { tenantId: tenant.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }), + tx.aIUsageRecord.groupBy({ by: ["type"], where: { tenantId: tenant.id, occurredAt: { gte: monthStart } }, _sum: { quantity: true } }), + ]), + ); + + const usage: Record = {}; + for (const row of usageByMeter) usage[row.meter] = row._sum.quantity ?? 0; + const aiUsage: Record = {}; + for (const row of aiUsageByType) aiUsage[row.type] = row._sum.quantity ?? 0; + + return { + tenantId: tenant.id, + legalName: tenant.legalName, + usage: { + callCount: usage["CALL_COUNT"] ?? 0, + callSeconds: usage["CALL_SECONDS"] ?? 0, + extensionActiveDays: usage["EXTENSION_ACTIVE_DAY"] ?? 0, + agentActiveDays: usage["AGENT_ACTIVE_DAY"] ?? 0, + trunkActiveDays: usage["TRUNK_ACTIVE_DAY"] ?? 0, + recordingBytes: usage["RECORDING_BYTES"] ?? 0, + }, + aiUsage: { + transcriptionSeconds: aiUsage["AI_TRANSCRIPTION_SECONDS"] ?? 0, + analysisRequests: aiUsage["AI_ANALYSIS_REQUEST"] ?? 0, + inputTokens: aiUsage["AI_INPUT_TOKENS"] ?? 0, + outputTokens: aiUsage["AI_OUTPUT_TOKENS"] ?? 0, + }, + }; + }), + ); + } +} diff --git a/apps/api/src/billing/billing.module.ts b/apps/api/src/billing/billing.module.ts index 9fb7469..11f0d0e 100644 --- a/apps/api/src/billing/billing.module.ts +++ b/apps/api/src/billing/billing.module.ts @@ -5,6 +5,7 @@ import { PlanVersionsController } from "./plan-versions.controller"; import { SubscriptionsController } from "./subscriptions.controller"; import { BillingPeriodsController } from "./billing-periods.controller"; import { BillingStatementsController } from "./billing-statements.controller"; +import { BillingConsumoController } from "./billing-consumo.controller"; @Module({ controllers: [ @@ -14,6 +15,7 @@ import { BillingStatementsController } from "./billing-statements.controller"; SubscriptionsController, BillingPeriodsController, BillingStatementsController, + BillingConsumoController, ], }) export class BillingModule {} diff --git a/apps/api/src/platform/platform-system-config.controller.ts b/apps/api/src/platform/platform-system-config.controller.ts new file mode 100644 index 0000000..5c20571 --- /dev/null +++ b/apps/api/src/platform/platform-system-config.controller.ts @@ -0,0 +1,45 @@ +import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common"; +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"; + +/** + * "Sistema > Configurações" (agente.md secao 169) — nunca teve escopo + * definido na especificação. Decisão desta implementação: painel + * read-only das flags de segurança/infra que já existem como variável de + * ambiente (`.env`, compartilhado por `apps/api` e os workers via + * systemd/docker-compose — ver `infrastructure/systemd/README.md` e + * `docker-compose.yml`), nunca editável por aqui — mudar exige editar o + * `.env` e reiniciar o serviço (documentado, não um botão de UI que + * fingiria aplicar na hora). Nunca expõe segredo nenhum (senha, chave, + * connection string) — só booleans/enums que já são público conhecimento + * de quem administra a infraestrutura. + */ +@UseGuards(JwtAuthGuard, PermissionGuard) +@Controller("platform/system-config") +export class PlatformSystemConfigController { + @RequirePermission("tenants.view") + @Get() + async get(@CurrentUser() user: AccessTokenClaims) { + if (!(await isPlatformUser(user.sub))) { + throw new ForbiddenException("So' um usuario com role de plataforma pode ver a configuracao do sistema"); + } + + const dialerSimulation = (process.env.DIALER_SIMULATION ?? "true") === "true"; + const allowRealOutboundCalls = (process.env.ALLOW_REAL_OUTBOUND_CALLS ?? "false") === "true"; + + return { + nodeEnv: process.env.NODE_ENV ?? "development", + dialerSimulation, + allowRealOutboundCalls, + // As DUAS precisam estar explicitamente ligadas (secao 186) — nunca + // basta uma pra originar PSTN de verdade. + realOutboundCallsActive: !dialerSimulation && allowRealOutboundCalls, + storageProvider: process.env.STORAGE_PROVIDER ?? "local", + eslConfigured: Boolean(process.env.ESL_HOST) && Boolean(process.env.ESL_PASSWORD), + corsOrigin: process.env.CORS_ORIGIN ?? null, + }; + } +} diff --git a/apps/api/src/platform/platform.module.ts b/apps/api/src/platform/platform.module.ts index 81c99b5..b45bdc2 100644 --- a/apps/api/src/platform/platform.module.ts +++ b/apps/api/src/platform/platform.module.ts @@ -7,6 +7,7 @@ import { PlatformRolesController } from "./platform-roles.controller"; import { PlatformQuotasController } from "./platform-quotas.controller"; import { PlatformFreeswitchController } from "./platform-freeswitch.controller"; import { PlatformAiUsageController } from "./platform-ai-usage.controller"; +import { PlatformSystemConfigController } from "./platform-system-config.controller"; @Module({ controllers: [ @@ -18,6 +19,7 @@ import { PlatformAiUsageController } from "./platform-ai-usage.controller"; PlatformQuotasController, PlatformFreeswitchController, PlatformAiUsageController, + PlatformSystemConfigController, ], }) export class PlatformModule {} diff --git a/apps/frontend/.impeccable/review/billing-consumo-desktop.png b/apps/frontend/.impeccable/review/billing-consumo-desktop.png new file mode 100644 index 0000000..a37177e Binary files /dev/null and b/apps/frontend/.impeccable/review/billing-consumo-desktop.png differ diff --git a/apps/frontend/.impeccable/review/sistema-configuracoes-desktop.png b/apps/frontend/.impeccable/review/sistema-configuracoes-desktop.png new file mode 100644 index 0000000..8f7393d Binary files /dev/null and b/apps/frontend/.impeccable/review/sistema-configuracoes-desktop.png differ diff --git a/apps/frontend/src/app/platform/billing/consumo/consumo-view.tsx b/apps/frontend/src/app/platform/billing/consumo/consumo-view.tsx new file mode 100644 index 0000000..9c40653 --- /dev/null +++ b/apps/frontend/src/app/platform/billing/consumo/consumo-view.tsx @@ -0,0 +1,52 @@ +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { EmptyState } from "@/components/ui/table"; +import { formatBytes, formatDuration, formatInt } from "@/lib/format"; +import type { TenantConsumo } from "@/lib/billing-types"; + +function Stat({ label, value }: { label: string; value: string | number }) { + return ( +
+ {label} + {value} +
+ ); +} + +export function ConsumoView({ consumo }: { consumo: TenantConsumo[] }) { + return ( +
+
+

Consumo

+

+ Uso bruto de todos os tenants no mês corrente — mesma agregação de Relatórios > Consumo (tenant), só + que aqui em todos de uma vez. Nunca em dinheiro (isso é Billing > Relatórios). +

+
+ + {consumo.length === 0 ? ( + + + + ) : ( + consumo.map((c) => { + const recording = formatBytes(c.usage.recordingBytes); + return ( + + +
+ + + + + + + + +
+
+ ); + }) + )} +
+ ); +} diff --git a/apps/frontend/src/app/platform/billing/consumo/page.tsx b/apps/frontend/src/app/platform/billing/consumo/page.tsx new file mode 100644 index 0000000..b7e3e2c --- /dev/null +++ b/apps/frontend/src/app/platform/billing/consumo/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { TenantConsumo } from "@/lib/billing-types"; +import { ConsumoView } from "./consumo-view"; + +export default async function BillingConsumoPage() { + const session = await requireSession(); + const consumo = await apiFetch("/billing/consumo", session.accessToken); + return ; +} diff --git a/apps/frontend/src/app/platform/sistema/configuracoes/configuracoes-view.tsx b/apps/frontend/src/app/platform/sistema/configuracoes/configuracoes-view.tsx new file mode 100644 index 0000000..0cf6cfd --- /dev/null +++ b/apps/frontend/src/app/platform/sistema/configuracoes/configuracoes-view.tsx @@ -0,0 +1,93 @@ +import { CircleCheck, CircleX } from "lucide-react"; +import { Panel, PanelHeader } from "@/components/ui/panel"; +import { Pill } from "@/components/ui/pill"; +import type { SystemConfig } from "@/lib/platform-types"; + +function FlagRow({ label, on, description }: { label: string; on: boolean; description: string }) { + return ( +
+
+

{label}

+

{description}

+
+ {on ? ( + + Ligado + + ) : ( + + Desligado + + )} +
+ ); +} + +export function ConfiguracoesView({ config }: { config: SystemConfig }) { + return ( +
+
+

Configurações do sistema

+

+ Somente leitura — estas flags vêm do .env compartilhado por + todos os serviços (agente.md secao 186). Mudar exige editar o arquivo e reiniciar o serviço, nunca um botão + aqui que fingiria aplicar na hora. +

+
+ + + +
+ + {config.realOutboundCallsActive ? "Chamadas PSTN reais ATIVAS" : "Nenhuma chamada PSTN real é feita"} + +
+

+ As duas flags abaixo precisam estar explicitamente na posição de risco ao mesmo tempo pra originar uma + chamada real — nunca ativado por padrão. +

+
+ + + + + + + + + +
+
+

Storage de gravações

+

Onde os arquivos de gravação são salvos.

+
+ {config.storageProvider} +
+
+
+

Ambiente (NODE_ENV)

+
+ {config.nodeEnv} +
+ {config.corsOrigin && ( +
+

CORS_ORIGIN

+ {config.corsOrigin} +
+ )} +
+
+ ); +} diff --git a/apps/frontend/src/app/platform/sistema/configuracoes/page.tsx b/apps/frontend/src/app/platform/sistema/configuracoes/page.tsx new file mode 100644 index 0000000..21f6cfc --- /dev/null +++ b/apps/frontend/src/app/platform/sistema/configuracoes/page.tsx @@ -0,0 +1,10 @@ +import { requireSession } from "@/lib/session"; +import { apiFetch } from "@/lib/api"; +import type { SystemConfig } from "@/lib/platform-types"; +import { ConfiguracoesView } from "./configuracoes-view"; + +export default async function SistemaConfiguracoesPage() { + const session = await requireSession(); + const config = await apiFetch("/platform/system-config", session.accessToken); + return ; +} diff --git a/apps/frontend/src/components/platform-shell/nav-data.ts b/apps/frontend/src/components/platform-shell/nav-data.ts index 9c4f5f8..5989a25 100644 --- a/apps/frontend/src/components/platform-shell/nav-data.ts +++ b/apps/frontend/src/components/platform-shell/nav-data.ts @@ -46,7 +46,11 @@ export const PLATFORM_NAV: NavSection[] = [ label: "Billing", icon: CreditCard, children: [ - { label: "Consumo" }, + { + label: "Consumo", + href: "/platform/billing/consumo", + description: "Uso bruto de todos os tenants no mês corrente", + }, { label: "Tarifas", href: "/platform/billing/tarifas", @@ -135,7 +139,11 @@ export const PLATFORM_NAV: NavSection[] = [ href: "/platform/sistema/auditoria", description: "Log de eventos de todos os tenants", }, - { label: "Configurações" }, + { + label: "Configurações", + href: "/platform/sistema/configuracoes", + description: "Flags de segurança/infra do .env — somente leitura", + }, ], }, ]; diff --git a/apps/frontend/src/lib/billing-types.ts b/apps/frontend/src/lib/billing-types.ts index 643d6f5..9e407c7 100644 --- a/apps/frontend/src/lib/billing-types.ts +++ b/apps/frontend/src/lib/billing-types.ts @@ -93,6 +93,25 @@ export interface BillingStatement { items?: BillingStatementItem[]; } +export interface TenantConsumo { + tenantId: string; + legalName: string; + usage: { + callCount: number; + callSeconds: number; + extensionActiveDays: number; + agentActiveDays: number; + trunkActiveDays: number; + recordingBytes: number; + }; + aiUsage: { + transcriptionSeconds: number; + analysisRequests: number; + inputTokens: number; + outputTokens: number; + }; +} + export interface PlanVersion { id: string; planId: string; diff --git a/apps/frontend/src/lib/platform-types.ts b/apps/frontend/src/lib/platform-types.ts index c53bb73..a2101a2 100644 --- a/apps/frontend/src/lib/platform-types.ts +++ b/apps/frontend/src/lib/platform-types.ts @@ -101,6 +101,16 @@ export interface FreeswitchNodes { gateways: unknown; } +export interface SystemConfig { + nodeEnv: string; + dialerSimulation: boolean; + allowRealOutboundCalls: boolean; + realOutboundCallsActive: boolean; + storageProvider: string; + eslConfigured: boolean; + corsOrigin: string | null; +} + export interface TenantAiUsage { tenantId: string; legalName: string;