import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, NotFoundException, Post, Param, UseGuards } from "@nestjs/common"; import { getPrismaClient, withTenantContext, type Prisma } 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 { CreateAIModelDto } from "./dto/create-ai-model.dto"; /** "ai_models" (agente.md secao 102-103) — modelos configuráveis por * provider, cada um com seu conjunto de capabilities e custos. */ @UseGuards(JwtAuthGuard, PermissionGuard) @Controller("ai/models") export class AIModelsController { @RequirePermission("ai.manage") @Post() async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateAIModelDto) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const provider = await withTenantContext(prisma, tenantId, (tx) => tx.aIProvider.findFirst({ where: { id: dto.providerId } }), ); if (!provider) throw new NotFoundException("Provider nao encontrado"); // Achado real (mesma classe já corrigida em `AIProvidersController`): // a RLS híbrida (secao 100, `OR tenant_id IS NULL`) deixa qualquer // tenant ENXERGAR um provider GLOBAL, mas escrever um modelo nele é // catálogo de plataforma — sem este check, qualquer Tenant Admin com // `ai.manage` (permission de escopo TENANT) conseguia injetar um // modelo no catálogo visível por TODOS os tenants. if (provider.scope === "GLOBAL" && !(await isPlatformUser(user.sub))) { throw new ForbiddenException("So' um usuario com role de plataforma pode adicionar modelo a um provider GLOBAL"); } const model = await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.create({ data: { tenantId: provider.tenantId, providerId: provider.id, externalModelId: dto.externalModelId, displayName: dto.displayName, capabilities: (dto.capabilities ?? []) as Prisma.AIModelCreateInput["capabilities"], inputCost: dto.inputCost, outputCost: dto.outputCost, audioCost: dto.audioCost, }, }), ); await recordAuditEvent(prisma, { action: "AI_MODEL_CREATE", tenantId: provider.tenantId, userId: user.sub, entityType: "ai_model", entityId: model.id, after: { displayName: model.displayName, externalModelId: model.externalModelId }, }); return model; } @RequirePermission("ai.view") @Get() async list(@CurrentUser() user: AccessTokenClaims) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; return withTenantContext(prisma, tenantId, (tx) => tx.aIModel.findMany({ where: { enabled: true }, orderBy: { displayName: "asc" } }), ); } @RequirePermission("ai.manage") @Delete(":id") @HttpCode(HttpStatus.NO_CONTENT) async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) { const prisma = getPrismaClient(); const tenantId = user.tenantId!; const model = await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.findFirst({ where: { id } })); if (!model) throw new NotFoundException(); // Mesmo achado do create(): sem este check, qualquer Tenant Admin com // `ai.manage` conseguia desabilitar um modelo GLOBAL (visível e // usado por todos os tenants) só por conhecer o id. if (model.tenantId == null && !(await isPlatformUser(user.sub))) { throw new ForbiddenException("So' um usuario com role de plataforma pode remover um modelo GLOBAL"); } if (model.tenantId != null && model.tenantId !== tenantId) { // Nunca deveria acontecer (RLS ja' filtra), mas nunca custa checar // explicitamente antes de uma escrita (secao 31/146). throw new NotFoundException(); } await withTenantContext(prisma, tenantId, (tx) => tx.aIModel.update({ where: { id }, data: { enabled: false } })); await recordAuditEvent(prisma, { action: "AI_MODEL_DELETE", tenantId: model.tenantId, userId: user.sub, entityType: "ai_model", entityId: id, }); } }