/** * AI module — budget guard service (Phase 12, TASK-0121). * * Wraps {@link createAiBudgetGuard} in an @Injectable so the gateway factory * and the AI services share ONE guard instance (one rate-window state, one * notification hook): * * - `guard.check(workspaceId)` — awaited by EVERY AI endpoint before the * gateway call (429 rate window / 402 AI_BUDGET_EXCEEDED, Postgres- * backed month-to-date, replica-consistent via Redis); * - the exhaustion hook — in-app notification to every workspace * OWNER/ADMIN (the `notification.created` stream event carries the email * channel via the worker's email decision) + the * `ai.budget_exceeded` audit event (actor = system; abuse indicator); * throttled to one per workspace per 24 h (the Redis NX slot inside the * guard, NOT here — a duplicate call within the window is a no-op). */ import { Inject, Injectable, OnModuleInit } from "@nestjs/common"; import type { Request } from "express"; import type Redis from "ioredis"; import type { PrismaClient } from "@prisma/client"; import { PrismaService } from "../../common/prisma.service.js"; import { RedisService } from "../../common/redis.service.js"; import { AuditService } from "../../common/audit.service.js"; import { logger } from "../../common/logger.js"; import { NotificationsService } from "../notifications/notifications.service.js"; import { createAiBudgetGuard, type AiBudgetGuard } from "./gateway-provider.js"; @Injectable() export class AiBudgetGuardService implements OnModuleInit { private _guard: AiBudgetGuard | null = null; constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(RedisService) private readonly redis: RedisService, @Inject(AuditService) private readonly audit: AuditService, @Inject(NotificationsService) private readonly notifications: NotificationsService ) {} onModuleInit(): void { this._guard = createAiBudgetGuard({ prisma: this.prisma as unknown as PrismaClient, redis: this.redis.getRedis() as Redis, onBudgetExceeded: (workspaceId, used, limit, req) => this.onBudgetExceeded(workspaceId, used, limit, req) }); } /** The guard (available after module init — Nest boots the module before * routing, so request-path access is always safe). */ get guard(): AiBudgetGuard { if (!this._guard) throw new Error("AiBudgetGuardService not initialized"); return this._guard; } /** The exhaustion side effect (see module doc). */ async onBudgetExceeded(workspaceId: string, used: number, limit: number, req?: Request): Promise { const prisma = this.prisma as unknown as PrismaClient; const admins = await prisma.workspaceMember.findMany({ where: { workspaceId, role: { in: ["OWNER", "ADMIN"] } }, select: { userId: true } }); const body = `The workspace AI token budget was reached (${used} used of ${limit} monthly). ` + `New AI calls are blocked until the limit is raised (workspace settings → usageLimits.aiMonthlyTokens).`; for (const admin of admins) { try { await this.notifications.notify({ workspaceId, userId: admin.userId, type: "AUTOMATION", title: "AI budget exceeded", body, link: { type: "workspace", id: workspaceId } }); } catch (err) { logger.warn(`ai budget notification failed for user ${admin.userId}: ${(err as Error).message}`); } } await this.audit.audit(req, undefined, { action: "ai.budget_exceeded", resourceType: "workspace", resourceId: workspaceId, workspaceId, metadata: { used, limit, adminsNotified: admins.length } }); } }