Files
B2BCall-dialer/apps/frontend/src/app/platform/ia/uso/uso-view.tsx
Matheus cc80310cb5 feat(frontend): Platform > IA > Providers/Modelos/Uso/Custos + achado real de autorização
Providers/Modelos reaproveitam os endpoints /ai/providers e /ai/models já
existentes (só filtram scope GLOBAL na tela); Modelos expõe custo unitário
(inputCost/outputCost/audioCost) que a tela de tenant nunca mostra, porque só
platform precisa cadastrar preço. GET /platform/ai-usage (novo) agrega uso bruto de
AIUsageRecord de todos os tenants no mês corrente (Uso) e estima custo casando cada
registro com o AIModel correspondente (Custos) — quando não dá pra casar, o registro
fica de fora da soma e o tenant é marcado costIncomplete, nunca um número inventado.

Achado real de autorização ao revisar AIModelsController antes de construir a tela
de Modelos: POST/DELETE /ai/models não checava isPlatformUser quando o alvo era
scope=GLOBAL — como a RLS híbrida (OR tenant_id IS NULL) deixa qualquer tenant
ENXERGAR um provider/modelo GLOBAL, qualquer Tenant Admin com `ai.manage` (permission
de escopo TENANT) conseguia injetar um modelo no catálogo global ou desabilitar um
modelo GLOBAL só sabendo o id. O endpoint irmão (AIProvidersController) já tinha o
check certo; corrigido com o mesmo padrão. Confirmado com um teste de ataque real:
403 depois do fix (era 201/sucesso antes), com regressão confirmando que BYOK do
próprio tenant continua funcionando normalmente.

Testado ponta a ponta: provider+modelo GLOBAL com custo real, uso de teste inserido
direto no Postgres, /platform/ai-usage devolvendo o valor exato esperado (bate com a
conta manual), tudo removido no final.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
2026-08-30 08:53:35 -03:00

53 lines
2.2 KiB
TypeScript

import { Panel, PanelHeader } from "@/components/ui/panel";
import { EmptyState, TBody, TD, TH, THead, TR, Table } from "@/components/ui/table";
import { formatDuration, formatInt } from "@/lib/format";
import type { TenantAiUsage } from "@/lib/platform-types";
export function UsoView({ usage }: { usage: TenantAiUsage[] }) {
const withUsage = usage.filter(
(u) => u.usage.transcriptionSeconds > 0 || u.usage.analysisRequests > 0 || u.usage.inputTokens > 0 || u.usage.outputTokens > 0,
);
return (
<div className="space-y-5">
<div>
<h1 className="text-lg font-semibold text-foreground">IA Uso</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Uso bruto de IA de todos os tenants no mês corrente (agente.md secao 124, ledger{" "}
<code className="font-mono text-xs">AIUsageRecord</code>) quantidade, não custo (isso é IA &gt; Custos).
</p>
</div>
<Panel>
<PanelHeader title="Uso por tenant" description={`${withUsage.length} de ${usage.length} tenant(s) com uso este mês`} />
{usage.length === 0 ? (
<EmptyState title="Nenhum tenant" description="Nenhum tenant cadastrado ainda." />
) : (
<Table>
<THead>
<TR>
<TH>Tenant</TH>
<TH>Transcrição</TH>
<TH>Análises</TH>
<TH>Tokens de entrada</TH>
<TH>Tokens de saída</TH>
</TR>
</THead>
<TBody>
{usage.map((u) => (
<TR key={u.tenantId}>
<TD className="text-foreground">{u.legalName}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatDuration(u.usage.transcriptionSeconds)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(u.usage.analysisRequests)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(u.usage.inputTokens)}</TD>
<TD className="font-mono tabular-nums text-muted-foreground">{formatInt(u.usage.outputTokens)}</TD>
</TR>
))}
</TBody>
</Table>
)}
</Panel>
</div>
);
}