Refactor dashboard queries to push filtering and aggregation into the database

DashboardReadService no longer loads full entity lists and filters in memory.
All job panels (today/overdue/in-progress) now execute targeted COUNT + capped
SELECT queries in SQL. AR aging buckets, powder order lines, bill totals, and
active-customer counts are all aggregated at the DB level. The SuperAdmin action
previously loaded every company row to compute plan distribution and alert lists;
it now delegates to a new GetSuperAdminDashboardDataAsync() that uses SQL GROUP BY
and projections instead.

DashboardIndexData record updated to carry pre-sliced counts and capped lists so
the controller only does lightweight DTO projection. DashboardPowderOrderLineData
replaces the deep Job→JobItem→Coat Include chains with a single flat coat query
projected in SQL. OnlineUserMiddleware switches its per-user throttle from a
static ConcurrentDictionary (grows forever) to IMemoryCache with a 60-second
sliding expiry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-01 10:00:43 -04:00
parent 0b798cadb4
commit 2b89fcf483
4 changed files with 647 additions and 457 deletions
@@ -1,4 +1,5 @@
using System.Security.Claims;
using Microsoft.Extensions.Caching.Memory;
using PowderCoating.Web.Services;
namespace PowderCoating.Web.Middleware;
@@ -10,22 +11,18 @@ namespace PowderCoating.Web.Middleware;
public class OnlineUserMiddleware
{
private readonly RequestDelegate _next;
/// <summary>
/// Maps each user ID to the UTC time the tracker was last updated.
/// A <see cref="System.Collections.Concurrent.ConcurrentDictionary{TKey,TValue}"/>
/// is used because multiple concurrent requests from the same user (e.g. polling
/// and a page navigation) may race to update the entry. The dictionary is
/// static so the throttle window persists across DI scope lifetimes — the
/// middleware instance itself may be recreated but the throttle state must not.
/// </summary>
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _throttle = new();
private readonly IMemoryCache _cache;
/// <summary>
/// Initialises the middleware with the next request delegate in the pipeline.
/// </summary>
/// <param name="next">The next middleware component.</param>
public OnlineUserMiddleware(RequestDelegate next) => _next = next;
/// <param name="cache">Shared app cache used for expiring per-user throttle entries.</param>
public OnlineUserMiddleware(RequestDelegate next, IMemoryCache cache)
{
_next = next;
_cache = cache;
}
/// <summary>
/// Calls the downstream pipeline first, then — after the response is
@@ -63,12 +60,15 @@ public class OnlineUserMiddleware
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId)) return;
// Throttle: only update the tracker once per 60 seconds per user
// Throttle: only update the tracker once per 60 seconds per user.
// IMemoryCache automatically expires old entries so the throttle state
// does not grow without bound across the lifetime of the process.
var now = DateTime.UtcNow;
if (_throttle.TryGetValue(userId, out var lastWrite) && (now - lastWrite).TotalSeconds < 60)
var throttleKey = $"online-user-touch:{userId}";
if (_cache.TryGetValue(throttleKey, out _))
return;
_throttle[userId] = now;
_cache.Set(throttleKey, true, TimeSpan.FromSeconds(60));
var email = context.User.FindFirstValue(ClaimTypes.Email) ?? string.Empty;
var firstName = context.User.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty;