feat: enforce account usage limits
CI and Deploy / test (push) Successful in 2m39s
CI and Deploy / deploy (push) Successful in 55s

This commit is contained in:
cesnimda
2026-07-30 23:14:42 +02:00
parent 9cd2e5c2e3
commit 158970fa01
7 changed files with 74 additions and 16 deletions
+19
View File
@@ -0,0 +1,19 @@
using JobTrackerApi.Models;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class AccountPlansTests
{
[Fact]
public void Premium_role_receives_higher_cost_capabilities()
{
var free = AccountPlans.ForRoles(Array.Empty<string>());
var premium = AccountPlans.ForRoles(new[] { "Premium" });
Assert.False(free.AdvancedAi);
Assert.True(premium.AdvancedAi);
Assert.True(premium.MonthlyAiCalls > free.MonthlyAiCalls);
Assert.True(premium.StorageBytes > free.StorageBytes);
}
}
@@ -22,7 +22,7 @@ public sealed class AiUsageController : ControllerBase
} }
public sealed record UsagePeriodDto(int Calls, long InputCharacters, long OutputCharacters, long EstimatedTokens); 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] [HttpGet]
public async Task<ActionResult<UsageDto>> Get(CancellationToken cancellationToken) public async Task<ActionResult<UsageDto>> Get(CancellationToken cancellationToken)
@@ -30,10 +30,14 @@ public sealed class AiUsageController : ControllerBase
var user = await _users.GetUserAsync(User); var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized(); 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); var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
return Ok(new UsageDto( 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 && 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) 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.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers; namespace JobTrackerApi.Controllers;
@@ -17,12 +18,14 @@ public sealed class AiWorkspaceController : ControllerBase
private readonly UserManager<ApplicationUser> _users; private readonly UserManager<ApplicationUser> _users;
private readonly IAiWorkspaceService _workspace; private readonly IAiWorkspaceService _workspace;
private readonly IConfiguration _config; 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; _users = users;
_workspace = workspace; _workspace = workspace;
_config = config; _config = config;
_db = db;
} }
public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext); 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 (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(request?.Module)) return BadRequest("Choose an AI module."); 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 try
{ {
var interaction = await _workspace.GenerateAsync( var interaction = await _workspace.GenerateAsync(
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using JobTrackerApi.Data; using JobTrackerApi.Data;
using JobTrackerApi.Models; using JobTrackerApi.Models;
using JobTrackerApi.Services; using JobTrackerApi.Services;
@@ -22,11 +23,13 @@ namespace JobTrackerApi.Controllers
private readonly AppPaths _paths; private readonly AppPaths _paths;
private readonly JobTrackerContext _db; 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; _paths = paths;
_db = db; _db = db;
_users = users;
} }
public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi); 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); var jobExists = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
if (!jobExists) return BadRequest("jobId does not exist."); 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()); var folder = Path.Combine(_paths.AttachmentsRoot, jobId.ToString());
Directory.CreateDirectory(folder); Directory.CreateDirectory(folder);
+3 -10
View File
@@ -73,7 +73,6 @@ public sealed class AuthController : ControllerBase
public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken); public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt); public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MicrosoftLinkDto(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( public sealed record MeResult(
string Provider, string Provider,
string? Id, string? Id,
@@ -87,7 +86,7 @@ public sealed class AuthController : ControllerBase
string? AvatarImageDataUrl, string? AvatarImageDataUrl,
IList<string> Roles, IList<string> Roles,
string Plan, string Plan,
EntitlementsDto Entitlements, AccountEntitlements Entitlements,
GoogleLinkDto? GoogleLink, GoogleLinkDto? GoogleLink,
MicrosoftLinkDto? MicrosoftLink); MicrosoftLinkDto? MicrosoftLink);
private const int MaxAvatarBytes = 1_000_000; private const int MaxAvatarBytes = 1_000_000;
@@ -397,7 +396,7 @@ public sealed class AuthController : ControllerBase
AvatarImageDataUrl: null, AvatarImageDataUrl: null,
Roles: Array.Empty<string>(), Roles: Array.Empty<string>(),
Plan: "free", Plan: "free",
Entitlements: BuildEntitlements(Array.Empty<string>()), Entitlements: AccountPlans.ForRoles(Array.Empty<string>()),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null, GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(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(); 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) private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
{ {
var entitlements = BuildEntitlements(roles); var entitlements = AccountPlans.ForRoles(roles);
return new MeResult( return new MeResult(
Provider: "local", Provider: "local",
Id: user.Id, Id: user.Id,
+14
View File
@@ -0,0 +1,14 @@
namespace JobTrackerApi.Models;
public sealed record AccountEntitlements(bool AdvancedAi, bool PremiumThemes, bool Automation, bool Analytics, long StorageBytes, int MonthlyAiCalls);
public static class AccountPlans
{
public static AccountEntitlements ForRoles(IList<string> roles)
{
var premium = roles.Contains("Premium", StringComparer.OrdinalIgnoreCase) || roles.Contains("Admin", StringComparer.OrdinalIgnoreCase);
return premium
? new AccountEntitlements(true, true, true, true, 5_000_000_000, 250)
: new AccountEntitlements(false, false, false, false, 250_000_000, 25);
}
}
+2 -2
View File
@@ -191,8 +191,8 @@ Goal: commercialise. Last, per the guide's "do not over-engineer before needed.
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 7.1 | **IMPLEMENTED; configuration required** — password signup and sign-in use Cloudflare Turnstile with mandatory server-side Siteverify validation when keys are configured. Registration remains closed until `AUTH_ALLOW_REGISTRATION=true` and production widget keys are supplied. | **P2** | **M** | 2.4, 7.3 | Safe code path is ready without silently opening public registration. | | 7.1 | **IMPLEMENTED; configuration required** — password signup and sign-in use Cloudflare Turnstile with mandatory server-side Siteverify validation when keys are configured. Registration remains closed until `AUTH_ALLOW_REGISTRATION=true` and production widget keys are supplied. | **P2** | **M** | 2.4, 7.3 | Safe code path is ready without silently opening public registration. |
| 7.2 | **DONE (2026-07-30)** — existing Identity roles are the plan model: `Premium` (and `Admin`) receives `advancedAi`, `premiumThemes`, `automation`, `analytics`, and 5 GB storage capabilities; free accounts receive core features and 250 MB. `/auth/me` exposes plan and entitlements. | **P3** | **M** | none | Reuses the existing role system and avoids a second billing-state table before Stripe exists. | | 7.2 | **DONE (2026-07-30)** — existing Identity roles are the plan model: `Premium` (and `Admin`) receives `advancedAi`, `premiumThemes`, `automation`, `analytics`, and 5 GB storage capabilities; free accounts receive core features and 250 MB. `/auth/me` exposes plan and entitlements. | **P3** | **M** | none | Reuses the existing role system and avoids a second billing-state table before Stripe exists. |
| 7.3 | **Usage quotas — AI + storage only** | **P3** | **M** | 5.2, 7.2 | **Do not open registration before this lands.** AI and storage are unmetered and unbounded; these are real cost, so they are legitimate limits. Job/CV counts are not. | | 7.3 | **DONE (2026-07-30)** — existing AI interaction metering now enforces monthly generation limits: 25 for free accounts and 250 for Premium/Admin. Usage responses expose the active plan and limit. | **P3** | **M** | 5.2, 7.2 | Cost-bearing AI now has a clear monthly ceiling before registration opens. |
| 7.4 | **Storage limits + attachment caps** | **P3** | **S** | 7.2 | The "more storage" premium lever. | | 7.4 | **DONE (2026-07-30)** — attachment uploads enforce total per-user storage entitlements (250 MB free, 5 GB Premium/Admin) in addition to the existing 10 MB per-file cap. | **P3** | **S** | 7.2 | Storage limits match the exposed capability model. |
| 7.5 | **Stripe billing** | **P3** | **L** | 7.2 | Still blocked on **Stripe keys** — the only remaining hard blocker. Tiers are now decided. | | 7.5 | **Stripe billing** | **P3** | **L** | 7.2 | Still blocked on **Stripe keys** — the only remaining hard blocker. Tiers are now decided. |
| 7.6 | **Public CV** (`/cv/{guid}`) | **P3** | **M** | 3.4, 4.2 | Documented in `docs/00-ai-context.md`; **zero code** — no route, no `IsPublic`, no slug. Privacy-first random GUID, no usernames. | | 7.6 | **Public CV** (`/cv/{guid}`) | **P3** | **M** | 3.4, 4.2 | Documented in `docs/00-ai-context.md`; **zero code** — no route, no `IsPublic`, no slug. Privacy-first random GUID, no usernames. |
| 7.7 | **Premium themes** | **P3** | **S** | 4.3, 7.2 | Trivial once themes are data. Impossible while they are C# methods. A decided premium lever. | | 7.7 | **Premium themes** | **P3** | **S** | 4.3, 7.2 | Trivial once themes are data. Impossible while they are C# methods. A decided premium lever. |