refactor(api): extract JobApplications DTOs and helpers, fix N+1 aggregation

- Move inline DTOs to JobApplicationDtos.cs, pure static helpers to JobApplicationHelpers.cs
- GetStats aggregates server-side (COUNT/GROUP BY) instead of loading the full table
- Cache RuleSettings via IMemoryCache, keyed per-user (RulesEngine.GetSettings falls back
  to per-user UserRuleSettings overrides, so a single global cache key would leak settings
  across users)
- Add missing AsNoTracking() to read-only GET endpoints (GetAll, GetById, GetBoard,
  GetReminders, GetStatusSuggestion, GetMatchScore, GetCandidateFit, GetFocusPlan,
  GetInterviewPrep, GetReadiness)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-12 20:08:59 +02:00
parent bd07876a41
commit ea6c3650f3
10 changed files with 936 additions and 875 deletions
+30 -19
View File
@@ -22,34 +22,45 @@ namespace JobTrackerApi.Services
public async Task<JobStats> GetStatsAsync(CancellationToken cancellationToken)
{
var now = DateTime.Now;
var last30 = now.AddDays(-30);
// Project to only the columns the stats need instead of materialising full
// JobApplication rows (which drag large Description/TranslatedDescription/
// TailoredCvText/Notes blobs). Aggregation stays in memory over a small
// per-tenant set.
var all = await _db.JobApplications
// Aggregate server-side (COUNT/GROUP BY) instead of pulling every row into memory.
var total = await _db.JobApplications.AsNoTracking().CountAsync(cancellationToken);
var active = await _db.JobApplications.AsNoTracking().CountAsync(j => !j.IsDeleted, cancellationToken);
var appliedLast30Days = await _db.JobApplications.AsNoTracking()
.CountAsync(j => !j.IsDeleted && j.DateApplied >= last30, cancellationToken);
var byStatus = await _db.JobApplications
.AsNoTracking()
.Select(j => new { j.IsDeleted, j.Status, j.DateApplied })
.Where(j => !j.IsDeleted)
.GroupBy(j => j.Status)
.Select(g => new { Status = g.Key, Count = g.Count() })
.ToListAsync(cancellationToken);
var active = all.Where(j => !j.IsDeleted).ToList();
var byStatusDict = byStatus
.GroupBy(x => string.IsNullOrWhiteSpace(x.Status) ? "Unknown" : x.Status)
.OrderByDescending(g => g.Sum(x => x.Count))
.ToDictionary(g => g.Key, g => g.Sum(x => x.Count));
var byStatus = active
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
// ponytail: average age needs a per-row day-diff that doesn't translate identically
// across the SQLite/MySQL providers this app runs on, so pull just the DateApplied
// column (no wide blob columns) for active rows and average client-side.
var activeDates = active == 0
? new List<DateTime>()
: await _db.JobApplications.AsNoTracking()
.Where(j => !j.IsDeleted)
.Select(j => j.DateApplied)
.ToListAsync(cancellationToken);
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
var avgDays = active.Count == 0
var avgDays = activeDates.Count == 0
? 0
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
: activeDates.Average(d => Math.Max(0, (now - d).TotalDays));
return new JobStats(
Total: all.Count,
Active: active.Count,
Deleted: all.Count - active.Count,
ByStatus: byStatus,
Total: total,
Active: active,
Deleted: total - active,
ByStatus: byStatusDict,
AppliedLast30Days: appliedLast30Days,
AverageDaysSinceApplied: Math.Round(avgDays, 1)
);