refactor(jobs): extract analytics controller
Keep the existing job application analytics routes while moving their queries behind a dedicated authenticated controller and shared tag parser.
This commit is contained in:
@@ -30,12 +30,11 @@ namespace JobTrackerApi.Controllers
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||
private readonly ICvPdfExporter _cvPdfExporter;
|
||||
private readonly AnalyticsService _analytics;
|
||||
private readonly IJobCvMatchService _matchService;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IApplicationChecklistService _checklist;
|
||||
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, UserManager<ApplicationUser> users, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null, IMemoryCache? cache = null, IApplicationChecklistService? checklist = null)
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, UserManager<ApplicationUser> users, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, IJobCvMatchService? matchService = null, IMemoryCache? cache = null, IApplicationChecklistService? checklist = null)
|
||||
{
|
||||
_checklist = checklist ?? new ApplicationChecklistService(db);
|
||||
_db = db;
|
||||
@@ -43,7 +42,6 @@ namespace JobTrackerApi.Controllers
|
||||
_users = users;
|
||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||
_analytics = analytics ?? new AnalyticsService(db);
|
||||
_matchService = matchService ?? new JobCvMatchService();
|
||||
_cache = cache ?? new MemoryCache(new MemoryCacheOptions());
|
||||
}
|
||||
@@ -1177,204 +1175,6 @@ Canonical profile:
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
|
||||
=> Ok(await _analytics.GetStatsAsync(cancellationToken));
|
||||
|
||||
[HttpGet("analytics")]
|
||||
public async Task<ActionResult<List<AnalyticsPoint>>> GetAnalytics(
|
||||
[FromQuery] int months = 12,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (months < 3) months = 3;
|
||||
if (months > 36) months = 36;
|
||||
|
||||
var now = DateTime.Now;
|
||||
|
||||
DateTime startMonth;
|
||||
DateTime endMonth;
|
||||
|
||||
if (from is not null || to is not null)
|
||||
{
|
||||
var toValue = to ?? now;
|
||||
var fromValue = from ?? toValue.AddMonths(-months);
|
||||
|
||||
if (toValue < fromValue)
|
||||
{
|
||||
(fromValue, toValue) = (toValue, fromValue);
|
||||
}
|
||||
|
||||
startMonth = new DateTime(fromValue.Year, fromValue.Month, 1);
|
||||
endMonth = new DateTime(toValue.Year, toValue.Month, 1).AddMonths(1);
|
||||
|
||||
var spanMonths = ((endMonth.Year - startMonth.Year) * 12) + (endMonth.Month - startMonth.Month);
|
||||
if (spanMonths < 3)
|
||||
{
|
||||
spanMonths = 3;
|
||||
startMonth = endMonth.AddMonths(-spanMonths);
|
||||
}
|
||||
|
||||
if (spanMonths > 36)
|
||||
{
|
||||
spanMonths = 36;
|
||||
startMonth = endMonth.AddMonths(-spanMonths);
|
||||
}
|
||||
|
||||
months = spanMonths;
|
||||
}
|
||||
else
|
||||
{
|
||||
endMonth = new DateTime(now.Year, now.Month, 1).AddMonths(1);
|
||||
startMonth = endMonth.AddMonths(-months);
|
||||
}
|
||||
|
||||
// DateApplied != null is explicit rather than implied by the range comparison: this is
|
||||
// applied-volume-per-month, so jobs that have not been applied to must not appear.
|
||||
var jobs = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.Where(j => !j.IsDeleted && j.DateApplied != null && j.DateApplied >= startMonth && j.DateApplied < endMonth)
|
||||
.Select(j => new { j.DateApplied, j.ResponseDate })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var applied = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var responses = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
|
||||
static string Key(DateTime d) => $"{d:yyyy-MM}";
|
||||
|
||||
foreach (var j in jobs)
|
||||
{
|
||||
var ak = Key(j.DateApplied!.Value);
|
||||
applied[ak] = (applied.TryGetValue(ak, out var av) ? av : 0) + 1;
|
||||
|
||||
if (j.ResponseDate is not null)
|
||||
{
|
||||
var rk = Key(j.ResponseDate.Value);
|
||||
responses[rk] = (responses.TryGetValue(rk, out var rv) ? rv : 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var outList = new List<AnalyticsPoint>(months);
|
||||
for (var i = 0; i < months; i++)
|
||||
{
|
||||
var m = startMonth.AddMonths(i);
|
||||
var k = Key(m);
|
||||
applied.TryGetValue(k, out var a);
|
||||
responses.TryGetValue(k, out var r);
|
||||
outList.Add(new AnalyticsPoint(k, a, r));
|
||||
}
|
||||
|
||||
return Ok(outList);
|
||||
}
|
||||
|
||||
[HttpGet("tags")]
|
||||
public async Task<ActionResult<List<TagPoint>>> GetTags(
|
||||
[FromQuery] int limit = 10,
|
||||
[FromQuery] DateTime? from = null,
|
||||
[FromQuery] DateTime? to = null,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
if (limit < 3) limit = 3;
|
||||
if (limit > 50) limit = 50;
|
||||
|
||||
IQueryable<JobApplication> query = _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.Where(j => !j.IsDeleted);
|
||||
|
||||
if (from is not null || to is not null)
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
var toValue = to ?? now;
|
||||
var fromValue = from ?? DateTime.MinValue;
|
||||
|
||||
if (toValue < fromValue)
|
||||
{
|
||||
(fromValue, toValue) = (toValue, fromValue);
|
||||
}
|
||||
|
||||
var startMonth = fromValue == DateTime.MinValue
|
||||
? (DateTime?)null
|
||||
: new DateTime(fromValue.Year, fromValue.Month, 1);
|
||||
var endMonth = new DateTime(toValue.Year, toValue.Month, 1).AddMonths(1);
|
||||
|
||||
if (startMonth is not null)
|
||||
{
|
||||
query = query.Where(j => j.DateApplied >= startMonth.Value);
|
||||
}
|
||||
query = query.Where(j => j.DateApplied < endMonth);
|
||||
}
|
||||
|
||||
var tagStrings = await query
|
||||
.Select(j => j.Tags)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
static IEnumerable<string> SplitTags(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) yield break;
|
||||
|
||||
var trimmed = s.Trim();
|
||||
|
||||
List<string>? jsonTags = null;
|
||||
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
|
||||
{
|
||||
try
|
||||
{
|
||||
jsonTags = JsonSerializer.Deserialize<List<string>>(trimmed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
jsonTags = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonTags is not null)
|
||||
{
|
||||
foreach (var x in jsonTags)
|
||||
{
|
||||
var t = (x ?? string.Empty).Trim();
|
||||
if (t.Length == 0) continue;
|
||||
yield return t;
|
||||
}
|
||||
yield break;
|
||||
}
|
||||
|
||||
foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var t = raw.Trim();
|
||||
if (t.Length == 0) continue;
|
||||
yield return t;
|
||||
}
|
||||
}
|
||||
|
||||
var map = new Dictionary<string, (string Display, int Count)>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var s in tagStrings)
|
||||
{
|
||||
foreach (var t in SplitTags(s))
|
||||
{
|
||||
if (map.TryGetValue(t, out var v))
|
||||
{
|
||||
map[t] = (v.Display, v.Count + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
map[t] = (t, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var outList = map.Values
|
||||
.OrderByDescending(x => x.Count)
|
||||
.ThenBy(x => x.Display, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(limit)
|
||||
.Select(x => new TagPoint(x.Display, x.Count))
|
||||
.ToList();
|
||||
|
||||
return Ok(outList);
|
||||
}
|
||||
|
||||
private static string BuildPackageModeInstruction(string? mode)
|
||||
{
|
||||
return (mode ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
@@ -2080,66 +1880,6 @@ Candidate master CV:
|
||||
RecruiterMessageVariants: recruiterMessageVariants));
|
||||
}
|
||||
|
||||
[HttpGet("analytics-overview")]
|
||||
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
|
||||
=> Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken));
|
||||
|
||||
[HttpGet("tag-trends")]
|
||||
public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
|
||||
[FromQuery] int months = 6,
|
||||
[FromQuery] int limit = 5,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (months < 3) months = 3;
|
||||
if (months > 24) months = 24;
|
||||
if (limit < 3) limit = 3;
|
||||
if (limit > 10) limit = 10;
|
||||
|
||||
var endMonth = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1).AddMonths(1);
|
||||
var startMonth = endMonth.AddMonths(-months);
|
||||
|
||||
var jobs = await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.Where(j => !j.IsDeleted && j.DateApplied >= startMonth && j.DateApplied < endMonth)
|
||||
.Select(j => new { j.DateApplied, j.Tags })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var overall = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
var monthKeys = Enumerable.Range(0, months).Select(i => startMonth.AddMonths(i).ToString("yyyy-MM")).ToList();
|
||||
var seriesMap = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
var key = $"{job.DateApplied:yyyy-MM}";
|
||||
foreach (var tag in SplitTags(job.Tags))
|
||||
{
|
||||
overall[tag] = (overall.TryGetValue(tag, out var count) ? count : 0) + 1;
|
||||
if (!seriesMap.TryGetValue(tag, out var byMonth))
|
||||
{
|
||||
byMonth = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
seriesMap[tag] = byMonth;
|
||||
}
|
||||
byMonth[key] = (byMonth.TryGetValue(key, out var monthCount) ? monthCount : 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
var topTags = overall
|
||||
.OrderByDescending(x => x.Value)
|
||||
.ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(limit)
|
||||
.Select(x => x.Key)
|
||||
.ToList();
|
||||
|
||||
var series = topTags
|
||||
.Select(tag => new TagTrendSeries(
|
||||
tag,
|
||||
monthKeys.Select(month => seriesMap.TryGetValue(tag, out var byMonth) && byMonth.TryGetValue(month, out var count) ? count : 0).ToList()
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return Ok(new TagTrendResponse(monthKeys, series));
|
||||
}
|
||||
|
||||
[HttpGet("duplicate-check")]
|
||||
public async Task<ActionResult<DuplicateCheckResult>> CheckDuplicates(
|
||||
[FromQuery] int companyId,
|
||||
|
||||
Reference in New Issue
Block a user