ac3dcc2f31
Addresses production CPU spike incident. Key changes: - Guard OTel exporter behind OTEL_EXPORTER_OTLP_ENDPOINT env var; filter tracing to /api paths only — unconditional export was primary suspect - Remove /healthz endpoint entirely (unauthenticated, hit DB on every call) - Replace KnownUserMiddleware with POST /api/users/me called once on login from TokenSync — eliminates unconditional DB write on every request - Add DB indexes: (BudgetId, IsDeleted) on Incomes/Outgos, OwnerUserId on Budgets, SharedWithUserId and (IsPending, SharedWithEmail) on BudgetShares - Move UseRateLimiter() before UseStaticFiles() so all requests are throttled - Replace full-array reorder with move-by-position (id + newIndex) — bounded input, fewer DB writes, better API design - Lock ForwardedHeaders to 172.20.0.0/16 subnet; fixes KnownNetworks deprecation warning (0 warnings in build now) - Add AsNoTracking() to all read-only queries in Summary/Incomes/OutgosController - FrequencyCalculator returns 0 for unknown enum values instead of throwing - Thread.Sleep → await Task.Delay in OIDC startup loop - AllowedHosts locked to budget.stwaddle.com Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
70 lines
2.8 KiB
C#
70 lines
2.8 KiB
C#
using Budget.Api.Services;
|
|
using Budget.Core.DTOs;
|
|
using Budget.Core.Models;
|
|
using Budget.Core.Services;
|
|
using Budget.Infrastructure.Data;
|
|
using Budget.Infrastructure.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Budget.Api.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/budgets/{budgetId:guid}/summary")]
|
|
[Authorize(Roles = "admin,user")]
|
|
public class SummaryController(AppDbContext db, BudgetAuthorizationService authz) : ControllerBase
|
|
{
|
|
private IActionResult? TryGetUserId(out string userId)
|
|
{
|
|
var id = User.GetUserId();
|
|
if (id is null) { userId = string.Empty; return Unauthorized(); }
|
|
userId = id;
|
|
return null;
|
|
}
|
|
|
|
[HttpGet]
|
|
public async Task<IActionResult> Get(Guid budgetId)
|
|
{
|
|
if (TryGetUserId(out var userId) is { } err) return err;
|
|
if (!await authz.CanReadAsync(budgetId, userId)) return Forbid();
|
|
|
|
var budget = await db.Budgets.AsNoTracking().FirstOrDefaultAsync(b => b.Id == budgetId);
|
|
if (budget is null) return NotFound();
|
|
|
|
var incomes = await db.Incomes.AsNoTracking().Where(i => i.BudgetId == budgetId).ToListAsync();
|
|
var outgos = await db.Outgos.AsNoTracking().Where(o => o.BudgetId == budgetId).ToListAsync();
|
|
|
|
var monthlyIncome = incomes.Sum(i => FrequencyCalculator.ToMonthly(i.Amount, i.Frequency));
|
|
var annualIncome = monthlyIncome * 12m;
|
|
|
|
var typeTargets = new Dictionary<OutgoType, int>
|
|
{
|
|
[OutgoType.Need] = 50,
|
|
[OutgoType.Want] = 30,
|
|
[OutgoType.Save] = 20,
|
|
};
|
|
|
|
var breakdown = new List<SummaryBreakdownItem>();
|
|
decimal totalMonthlyOutgo = 0m;
|
|
|
|
foreach (var (type, target) in typeTargets)
|
|
{
|
|
var typeOutgos = outgos.Where(o => o.Type == type).ToList();
|
|
var monthlyTotal = typeOutgos.Sum(o => FrequencyCalculator.ToMonthly(o.Amount, o.Frequency));
|
|
var annualTotal = monthlyTotal * 12m;
|
|
totalMonthlyOutgo += monthlyTotal;
|
|
var percent = monthlyIncome > 0 ? Math.Round(monthlyTotal / monthlyIncome * 100, 2) : 0m;
|
|
var maxAmount = monthlyIncome * target / 100m;
|
|
var remaining = maxAmount - monthlyTotal;
|
|
breakdown.Add(new SummaryBreakdownItem(type.ToString(), target, monthlyTotal, annualTotal, percent, maxAmount, remaining));
|
|
}
|
|
|
|
var unspent = monthlyIncome - totalMonthlyOutgo;
|
|
var unspentPercent = monthlyIncome > 0 ? Math.Round(unspent / monthlyIncome * 100, 2) : 0m;
|
|
breakdown.Add(new SummaryBreakdownItem("Unspent", null, unspent, unspent * 12m, unspentPercent, null, null));
|
|
|
|
return Ok(new SummaryDto(monthlyIncome, annualIncome, breakdown));
|
|
}
|
|
}
|