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:
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user