diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 2795bf5..35bcf26 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -23,8 +23,9 @@ namespace JobTrackerApi.Controllers private readonly ILogger _logger; private readonly ICvTemplateRenderer _cvTemplateRenderer; private readonly ICvPdfExporter _cvPdfExporter; + private readonly AnalyticsService _analytics; - public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null) + public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager users, ILogger 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 ByStatus, - int AppliedLast30Days, - double AverageDaysSinceApplied - ); - [HttpGet("stats")] public async Task> 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 Counts); public sealed record TagTrendPoint(string Month, List Counts); - public sealed record AnalyticsOverviewDto( - List Funnel, - List ResponseRateBySource, - List 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 Matches); public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn, string ContextSummary, List ContextSignals, string? ThreadSubject, string? LastCorrespondenceFrom, DateTime? LastCorrespondenceAt); @@ -2672,86 +2621,7 @@ Candidate master CV: [HttpGet("analytics-overview")] public async Task> 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 - { - ["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> GetTagTrends( diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index a76b189..b914b42 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -153,6 +153,7 @@ builder.Services.AddHttpClient("ai-service", client => }); builder.Services.AddMemoryCache(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/AnalyticsService.cs b/JobTrackerApi/Services/AnalyticsService.cs new file mode 100644 index 0000000..3b95ecd --- /dev/null +++ b/JobTrackerApi/Services/AnalyticsService.cs @@ -0,0 +1,140 @@ +using Microsoft.EntityFrameworkCore; +using JobTrackerApi.Data; +using JobTrackerApi.Models; + +namespace JobTrackerApi.Services +{ + /// + /// Read-only analytics/statistics aggregation extracted from JobApplicationsController. + /// Uses the tenant-scoped , so the global OwnerUserId + /// query filters apply automatically. Behaviour is identical to the former inline + /// controller methods (GetStats / GetAnalyticsOverview). + /// + public sealed class AnalyticsService + { + private readonly JobTrackerContext _db; + + public AnalyticsService(JobTrackerContext db) + { + _db = db; + } + + public async Task 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 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 + { + ["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 + ); + } + } +} diff --git a/Models/AnalyticsDtos.cs b/Models/AnalyticsDtos.cs new file mode 100644 index 0000000..046cdfb --- /dev/null +++ b/Models/AnalyticsDtos.cs @@ -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 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 Funnel, + List ResponseRateBySource, + List TopCompanies, + double? MedianDaysToFirstResponse, + int TotalResponses, + int TotalActive + ); +}