From 158970fa01aebdc2a00e6706170f4fd4055802e1 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 30 Jul 2026 23:14:42 +0200 Subject: [PATCH] feat: enforce account usage limits --- JobTrackerApi.Tests/AccountPlansTests.cs | 19 +++++++++++++++++++ .../Controllers/AiUsageController.cs | 8 ++++++-- .../Controllers/AiWorkspaceController.cs | 14 +++++++++++++- .../Controllers/AttachmentsController.cs | 18 +++++++++++++++++- JobTrackerApi/Controllers/AuthController.cs | 13 +++---------- Models/AccountPlans.cs | 14 ++++++++++++++ docs/implementation-roadmap.md | 4 ++-- 7 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 JobTrackerApi.Tests/AccountPlansTests.cs create mode 100644 Models/AccountPlans.cs diff --git a/JobTrackerApi.Tests/AccountPlansTests.cs b/JobTrackerApi.Tests/AccountPlansTests.cs new file mode 100644 index 0000000..776e9bb --- /dev/null +++ b/JobTrackerApi.Tests/AccountPlansTests.cs @@ -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()); + 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); + } +} diff --git a/JobTrackerApi/Controllers/AiUsageController.cs b/JobTrackerApi/Controllers/AiUsageController.cs index 159ccbd..891f5cf 100644 --- a/JobTrackerApi/Controllers/AiUsageController.cs +++ b/JobTrackerApi/Controllers/AiUsageController.cs @@ -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> 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 SumAsync(IQueryable query, CancellationToken cancellationToken) diff --git a/JobTrackerApi/Controllers/AiWorkspaceController.cs b/JobTrackerApi/Controllers/AiWorkspaceController.cs index 282edb3..8e45aec 100644 --- a/JobTrackerApi/Controllers/AiWorkspaceController.cs +++ b/JobTrackerApi/Controllers/AiWorkspaceController.cs @@ -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 _users; private readonly IAiWorkspaceService _workspace; private readonly IConfiguration _config; + private readonly JobTrackerApi.Data.JobTrackerContext? _db; - public AiWorkspaceController(UserManager users, IAiWorkspaceService workspace, IConfiguration config) + public AiWorkspaceController(UserManager 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( diff --git a/JobTrackerApi/Controllers/AttachmentsController.cs b/JobTrackerApi/Controllers/AttachmentsController.cs index fbf9146..e4a154e 100644 --- a/JobTrackerApi/Controllers/AttachmentsController.cs +++ b/JobTrackerApi/Controllers/AttachmentsController.cs @@ -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? _users; - public AttachmentsController(AppPaths paths, JobTrackerContext db) + public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager? 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); diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 3d3e58f..867032f 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -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 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(), Plan: "free", - Entitlements: BuildEntitlements(Array.Empty()), + Entitlements: AccountPlans.ForRoles(Array.Empty()), 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 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 roles) { - var entitlements = BuildEntitlements(roles); + var entitlements = AccountPlans.ForRoles(roles); return new MeResult( Provider: "local", Id: user.Id, diff --git a/Models/AccountPlans.cs b/Models/AccountPlans.cs new file mode 100644 index 0000000..c256a8c --- /dev/null +++ b/Models/AccountPlans.cs @@ -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 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); + } +} diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index 9388073..330b2e1 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -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.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.4 | **Storage limits + attachment caps** | **P3** | **S** | 7.2 | The "more storage" premium lever. | +| 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 | **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.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. |