Files
jobtrackingapp/JobTrackerApi/Controllers/AiWorkspaceController.cs
T
cesnimda 158970fa01
CI and Deploy / test (push) Successful in 2m39s
CI and Deploy / deploy (push) Successful in 55s
feat: enforce account usage limits
2026-07-30 23:14:42 +02:00

103 lines
4.7 KiB
C#

using System.Text.Json;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
// Phase 5 — the AI Workspace for one job application. Every module runs through ISummarizerService and
// is stored as append-only history; nothing is applied automatically. docs/architecture/ai-career-assistant.md.
[ApiController]
[Route("api/jobapplications/{jobId:int}/ai")]
[Authorize(AuthenticationSchemes = "local")]
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, JobTrackerApi.Data.JobTrackerContext? db = null)
{
_users = users;
_workspace = workspace;
_config = config;
_db = db;
}
public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext);
public sealed record InteractionDto(int Id, string Module, string? Mode, string Title, string Provider, JsonElement Result, int InputCharacterCount, int OutputCharacterCount, int EstimatedTokenCount, DateTimeOffset CreatedAtUtc);
[HttpGet("modules")]
public ActionResult<object> Modules() => Ok(new { modules = _workspace.Modules, provider = ResolveProvider() });
[HttpPost("generate")]
public async Task<ActionResult<InteractionDto>> Generate(int jobId, [FromBody] GenerateRequest request, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
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(
user.Id, jobId, user.ProfileCvText ?? string.Empty, ResolveName(user),
new AiGenerateRequest(request.Module, request.Mode, request.ExtraContext), ResolveProvider(), ct);
return interaction is null ? NotFound() : Ok(ToDto(interaction));
}
catch (ArgumentException ex)
{
return BadRequest(ex.Message);
}
catch (AiUnavailableException ex)
{
return StatusCode(StatusCodes.Status502BadGateway, ex.Message);
}
}
[HttpGet("history")]
public async Task<ActionResult<IEnumerable<InteractionDto>>> History(int jobId, [FromQuery] string? module, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var history = await _workspace.HistoryAsync(user.Id, jobId, module, ct);
return Ok(history.Select(ToDto));
}
[HttpDelete("history/{id:int}")]
public async Task<IActionResult> Delete(int jobId, int id, CancellationToken ct)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return await _workspace.DeleteAsync(user.Id, id, ct) ? NoContent() : NotFound();
}
private string ResolveProvider() =>
_config["Ai:Provider"] ?? Environment.GetEnvironmentVariable("AI_PROVIDER") ?? "ai-service";
private static string ResolveName(ApplicationUser user)
{
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim();
return name ?? string.Empty;
}
private static InteractionDto ToDto(AiInteraction x) => new(
x.Id, x.Module, x.Mode, x.Title, x.Provider,
JsonSerializer.Deserialize<JsonElement>(string.IsNullOrWhiteSpace(x.ResultJson) ? "{}" : x.ResultJson),
x.InputCharacterCount, x.OutputCharacterCount, x.EstimatedTokenCount, x.CreatedAtUtc);
}