refactor(analytics): extract AnalyticsService from JobApplicationsController

First Wave 2 (safe refactor) slice. Move the read-only stats/overview aggregation
out of the 3.3k-line JobApplicationsController into a dedicated, injectable
AnalyticsService, and lift its response DTOs (JobStats, FunnelStagePoint,
ResponseRatePoint, CompanyActivityPoint, AnalyticsOverviewDto) into
Models/AnalyticsDtos.cs.

- GetStats: ~44 lines -> 3 (delegates to AnalyticsService.GetStatsAsync).
- GetAnalyticsOverview: ~82 lines -> 3 (delegates to GetAnalyticsOverviewAsync).
- Registered AddScoped<AnalyticsService>(); controller keeps an optional ctor
  param with a `?? new AnalyticsService(db)` fallback so the 6 test sites that
  construct the controller directly keep compiling.
- Logic is byte-identical (same tenant-scoped context, same projections) so
  behaviour is preserved.

Backend suite: 92/92 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-05 10:51:08 +02:00
parent 824251d328
commit 2989a6fa2c
4 changed files with 176 additions and 135 deletions
@@ -23,8 +23,9 @@ namespace JobTrackerApi.Controllers
private readonly ILogger<JobApplicationsController> _logger;
private readonly ICvTemplateRenderer _cvTemplateRenderer;
private readonly ICvPdfExporter _cvPdfExporter;
private readonly AnalyticsService _analytics;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null)
{
_db = db;
_summarizer = summarizer;
@@ -33,6 +34,7 @@ namespace JobTrackerApi.Controllers
_logger = logger;
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_analytics = analytics ?? new AnalyticsService(db);
}
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
@@ -1736,51 +1738,9 @@ Canonical profile:
return Ok(all);
}
public sealed record JobStats(
int Total,
int Active,
int Deleted,
Dictionary<string, int> ByStatus,
int AppliedLast30Days,
double AverageDaysSinceApplied
);
[HttpGet("stats")]
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
{
var now = DateTime.Now;
// 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. Mirrors the projection pattern used by GetTagTrends.
var all = await _db.JobApplications
.AsNoTracking()
.Select(j => new { j.IsDeleted, j.Status, j.DateApplied })
.ToListAsync(cancellationToken);
var active = all.Where(j => !j.IsDeleted).ToList();
var byStatus = active
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
var avgDays = active.Count == 0
? 0
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
return Ok(new JobStats(
Total: all.Count,
Active: active.Count,
Deleted: all.Count - active.Count,
ByStatus: byStatus,
AppliedLast30Days: appliedLast30Days,
AverageDaysSinceApplied: Math.Round(avgDays, 1)
));
}
=> Ok(await _analytics.GetStatsAsync(cancellationToken));
public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
[HttpGet("analytics")]
@@ -1977,19 +1937,8 @@ Canonical profile:
return Ok(outList);
}
public sealed record FunnelStagePoint(string Label, int Count);
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
public sealed record TagTrendSeries(string Tag, List<int> Counts);
public sealed record TagTrendPoint(string Month, List<int> Counts);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
List<CompanyActivityPoint> TopCompanies,
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive
);
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List<string> ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt);
@@ -2672,86 +2621,7 @@ Candidate master CV:
[HttpGet("analytics-overview")]
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
{
// Project to only the fields the overview needs instead of Include-ing full
// Company + JobApplication rows (avoids loading large description/CV blobs).
var activeJobs = await _db.JobApplications
.AsNoTracking()
.Where(j => !j.IsDeleted)
.Select(j => new
{
j.Status,
j.ResponseReceived,
j.ResponseDate,
j.DateApplied,
j.CompanyId,
CompanyName = j.Company.Name,
CompanySource = j.Company.Source
})
.ToListAsync(cancellationToken);
var funnelMap = new Dictionary<string, int>
{
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
};
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList();
var responseRateBySource = activeJobs
.GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
.Select(g => new ResponseRatePoint(
g.Key,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Total)
.ThenByDescending(x => x.Rate)
.Take(6)
.ToList();
var topCompanies = activeJobs
.GroupBy(j => new { j.CompanyId, Name = j.CompanyName })
.Select(g => new CompanyActivityPoint(
g.Key.CompanyId,
g.Key.Name,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Count)
.ThenByDescending(x => x.ResponseRate)
.Take(8)
.ToList();
var responseDays = activeJobs
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
.OrderBy(x => x)
.ToList();
double? medianDays = null;
if (responseDays.Count > 0)
{
var mid = responseDays.Count / 2;
medianDays = responseDays.Count % 2 == 0
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
: Math.Round(responseDays[mid], 1);
}
return Ok(new AnalyticsOverviewDto(
Funnel: funnel,
ResponseRateBySource: responseRateBySource,
TopCompanies: topCompanies,
MedianDaysToFirstResponse: medianDays,
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
TotalActive: activeJobs.Count
));
}
=> Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken));
[HttpGet("tag-trends")]
public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
+1
View File
@@ -153,6 +153,7 @@ builder.Services.AddHttpClient("ai-service", client =>
});
builder.Services.AddMemoryCache();
builder.Services.AddScoped<AnalyticsService>();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
+140
View File
@@ -0,0 +1,140 @@
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services
{
/// <summary>
/// Read-only analytics/statistics aggregation extracted from JobApplicationsController.
/// Uses the tenant-scoped <see cref="JobTrackerContext"/>, so the global OwnerUserId
/// query filters apply automatically. Behaviour is identical to the former inline
/// controller methods (GetStats / GetAnalyticsOverview).
/// </summary>
public sealed class AnalyticsService
{
private readonly JobTrackerContext _db;
public AnalyticsService(JobTrackerContext db)
{
_db = db;
}
public async Task<JobStats> GetStatsAsync(CancellationToken cancellationToken)
{
var now = DateTime.Now;
// 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
.AsNoTracking()
.Select(j => new { j.IsDeleted, j.Status, j.DateApplied })
.ToListAsync(cancellationToken);
var active = all.Where(j => !j.IsDeleted).ToList();
var byStatus = active
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
var avgDays = active.Count == 0
? 0
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
return new JobStats(
Total: all.Count,
Active: active.Count,
Deleted: all.Count - active.Count,
ByStatus: byStatus,
AppliedLast30Days: appliedLast30Days,
AverageDaysSinceApplied: Math.Round(avgDays, 1)
);
}
public async Task<AnalyticsOverviewDto> GetAnalyticsOverviewAsync(CancellationToken cancellationToken)
{
// Project to only the fields the overview needs instead of Include-ing full
// Company + JobApplication rows (avoids loading large description/CV blobs).
var activeJobs = await _db.JobApplications
.AsNoTracking()
.Where(j => !j.IsDeleted)
.Select(j => new
{
j.Status,
j.ResponseReceived,
j.ResponseDate,
j.DateApplied,
j.CompanyId,
CompanyName = j.Company.Name,
CompanySource = j.Company.Source
})
.ToListAsync(cancellationToken);
var funnelMap = new Dictionary<string, int>
{
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
};
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList();
var responseRateBySource = activeJobs
.GroupBy(j => string.IsNullOrWhiteSpace(j.CompanySource) ? "Unknown source" : j.CompanySource!.Trim())
.Select(g => new ResponseRatePoint(
g.Key,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Total)
.ThenByDescending(x => x.Rate)
.Take(6)
.ToList();
var topCompanies = activeJobs
.GroupBy(j => new { j.CompanyId, Name = j.CompanyName })
.Select(g => new CompanyActivityPoint(
g.Key.CompanyId,
g.Key.Name,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Count)
.ThenByDescending(x => x.ResponseRate)
.Take(8)
.ToList();
var responseDays = activeJobs
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
.OrderBy(x => x)
.ToList();
double? medianDays = null;
if (responseDays.Count > 0)
{
var mid = responseDays.Count / 2;
medianDays = responseDays.Count % 2 == 0
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
: Math.Round(responseDays[mid], 1);
}
return new AnalyticsOverviewDto(
Funnel: funnel,
ResponseRateBySource: responseRateBySource,
TopCompanies: topCompanies,
MedianDaysToFirstResponse: medianDays,
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
TotalActive: activeJobs.Count
);
}
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Collections.Generic;
namespace JobTrackerApi.Models
{
// Read-only analytics/statistics response DTOs. Extracted from
// JobApplicationsController so the aggregation logic can live in AnalyticsService.
public sealed record JobStats(
int Total,
int Active,
int Deleted,
Dictionary<string, int> ByStatus,
int AppliedLast30Days,
double AverageDaysSinceApplied
);
public sealed record FunnelStagePoint(string Label, int Count);
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
List<CompanyActivityPoint> TopCompanies,
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive
);
}