feat: enforce account usage limits
This commit is contained in:
@@ -22,7 +22,7 @@ public sealed class AiUsageController : ControllerBase
|
||||
}
|
||||
|
||||
public sealed record UsagePeriodDto(int Calls, long InputCharacters, long OutputCharacters, long EstimatedTokens);
|
||||
public sealed record UsageDto(UsagePeriodDto CurrentMonth, UsagePeriodDto AllTime);
|
||||
public sealed record UsageDto(UsagePeriodDto CurrentMonth, UsagePeriodDto AllTime, string Plan, int MonthlyCallLimit);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<UsageDto>> Get(CancellationToken cancellationToken)
|
||||
@@ -30,10 +30,14 @@ public sealed class AiUsageController : ControllerBase
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var roles = await _users.GetRolesAsync(user);
|
||||
var entitlements = AccountPlans.ForRoles(roles);
|
||||
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
return Ok(new UsageDto(
|
||||
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart), cancellationToken),
|
||||
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id), cancellationToken)));
|
||||
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id), cancellationToken),
|
||||
entitlements.AdvancedAi ? "premium" : "free",
|
||||
entitlements.MonthlyAiCalls));
|
||||
}
|
||||
|
||||
private static async Task<UsagePeriodDto> SumAsync(IQueryable<AiInteraction> query, CancellationToken cancellationToken)
|
||||
|
||||
@@ -4,6 +4,7 @@ using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
@@ -17,12 +18,14 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly IAiWorkspaceService _workspace;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly JobTrackerApi.Data.JobTrackerContext? _db;
|
||||
|
||||
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config)
|
||||
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null)
|
||||
{
|
||||
_users = users;
|
||||
_workspace = workspace;
|
||||
_config = config;
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext);
|
||||
@@ -38,6 +41,15 @@ public sealed class AiWorkspaceController : ControllerBase
|
||||
if (user is null) return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(request?.Module)) return BadRequest("Choose an AI module.");
|
||||
|
||||
if (_db is not null)
|
||||
{
|
||||
var roles = await _users.GetRolesAsync(user);
|
||||
var limit = AccountPlans.ForRoles(roles).MonthlyAiCalls;
|
||||
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var used = await _db.AiInteractions.CountAsync(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart, ct);
|
||||
if (used >= limit) return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI limit reached ({limit} generations). Upgrade your plan or try again next month.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var interaction = await _workspace.GenerateAsync(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
@@ -22,11 +23,13 @@ namespace JobTrackerApi.Controllers
|
||||
|
||||
private readonly AppPaths _paths;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly UserManager<ApplicationUser>? _users;
|
||||
|
||||
public AttachmentsController(AppPaths paths, JobTrackerContext db)
|
||||
public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager<ApplicationUser>? users = null)
|
||||
{
|
||||
_paths = paths;
|
||||
_db = db;
|
||||
_users = users;
|
||||
}
|
||||
|
||||
public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi);
|
||||
@@ -202,6 +205,19 @@ namespace JobTrackerApi.Controllers
|
||||
var jobExists = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
|
||||
if (!jobExists) return BadRequest("jobId does not exist.");
|
||||
|
||||
|
||||
if (_users is not null)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
var roles = await _users.GetRolesAsync(user);
|
||||
var limit = AccountPlans.ForRoles(roles).StorageBytes;
|
||||
var used = await _db.Attachments.Where(a => a.JobApplication.OwnerUserId == user.Id).SumAsync(a => (long?)a.FileSize, cancellationToken) ?? 0;
|
||||
var incoming = files.Sum(file => file.Length);
|
||||
if (incoming > limit - used)
|
||||
return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Storage limit reached ({limit / 1_000_000} MB). Remove files or upgrade your plan.");
|
||||
}
|
||||
|
||||
var folder = Path.Combine(_paths.AttachmentsRoot, jobId.ToString());
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ public sealed class AuthController : ControllerBase
|
||||
public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
|
||||
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
|
||||
public sealed record EntitlementsDto(bool AdvancedAi, bool PremiumThemes, bool Automation, bool Analytics, long StorageBytes);
|
||||
public sealed record MeResult(
|
||||
string Provider,
|
||||
string? Id,
|
||||
@@ -87,7 +86,7 @@ public sealed class AuthController : ControllerBase
|
||||
string? AvatarImageDataUrl,
|
||||
IList<string> Roles,
|
||||
string Plan,
|
||||
EntitlementsDto Entitlements,
|
||||
AccountEntitlements Entitlements,
|
||||
GoogleLinkDto? GoogleLink,
|
||||
MicrosoftLinkDto? MicrosoftLink);
|
||||
private const int MaxAvatarBytes = 1_000_000;
|
||||
@@ -397,7 +396,7 @@ public sealed class AuthController : ControllerBase
|
||||
AvatarImageDataUrl: null,
|
||||
Roles: Array.Empty<string>(),
|
||||
Plan: "free",
|
||||
Entitlements: BuildEntitlements(Array.Empty<string>()),
|
||||
Entitlements: AccountPlans.ForRoles(Array.Empty<string>()),
|
||||
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
|
||||
MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null));
|
||||
}
|
||||
@@ -910,15 +909,9 @@ public sealed class AuthController : ControllerBase
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static EntitlementsDto BuildEntitlements(IList<string> roles)
|
||||
{
|
||||
var premium = roles.Contains("Premium", StringComparer.OrdinalIgnoreCase) || roles.Contains("Admin", StringComparer.OrdinalIgnoreCase);
|
||||
return new EntitlementsDto(premium, premium, premium, premium, premium ? 5_000_000_000 : 250_000_000);
|
||||
}
|
||||
|
||||
private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
|
||||
{
|
||||
var entitlements = BuildEntitlements(roles);
|
||||
var entitlements = AccountPlans.ForRoles(roles);
|
||||
return new MeResult(
|
||||
Provider: "local",
|
||||
Id: user.Id,
|
||||
|
||||
Reference in New Issue
Block a user