merge: reconcile perf/wave1-perf with main (Wave 0 features)
Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut: - useViewResource.ts: main'se352aaealready fixes the render loop the same way (load in a ref, dropped from deps) — took main's canonical version. My independent fix is superseded (my branch predatede352aae, which is why the loop reproduced live). - JobApplicationsController.cs: keep BOTH main's IJobCvMatchService and my AnalyticsService (ctor gets both optional params). GetAnalyticsOverview stays delegated to AnalyticsService. - Fold main's H3 additions into the extracted AnalyticsService: pipeline-driven funnel (JobPipeline.Normalize/Stages) + time-in-stage (StageAnalytics) and add StageDurationDto + TimeInStage to Models/AnalyticsDtos.cs, preserving the API contract the frontend expects. Build clean; backend suite 135/135 green.
This commit is contained in:
@@ -24,8 +24,9 @@ namespace JobTrackerApi.Controllers
|
||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||
private readonly ICvPdfExporter _cvPdfExporter;
|
||||
private readonly AnalyticsService _analytics;
|
||||
private readonly IJobCvMatchService _matchService;
|
||||
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null)
|
||||
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null)
|
||||
{
|
||||
_db = db;
|
||||
_summarizer = summarizer;
|
||||
@@ -35,6 +36,7 @@ namespace JobTrackerApi.Controllers
|
||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||
_analytics = analytics ?? new AnalyticsService(db);
|
||||
_matchService = matchService ?? new JobCvMatchService();
|
||||
}
|
||||
|
||||
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
||||
@@ -751,6 +753,10 @@ Canonical profile:
|
||||
Deadline: job.Deadline,
|
||||
Location: job.Location,
|
||||
Salary: job.Salary,
|
||||
SalaryMin: job.SalaryMin,
|
||||
SalaryMax: job.SalaryMax,
|
||||
SalaryCurrency: job.SalaryCurrency,
|
||||
SalaryPeriod: job.SalaryPeriod,
|
||||
NextAction: job.NextAction,
|
||||
FollowUpAt: job.FollowUpAt,
|
||||
FeedbackRequestedAt: job.FeedbackRequestedAt,
|
||||
@@ -1083,6 +1089,10 @@ Canonical profile:
|
||||
DateTime? Deadline,
|
||||
string? Location,
|
||||
string? Salary,
|
||||
decimal? SalaryMin,
|
||||
decimal? SalaryMax,
|
||||
string? SalaryCurrency,
|
||||
string? SalaryPeriod,
|
||||
string? NextAction,
|
||||
DateTime? FollowUpAt,
|
||||
DateTime? FeedbackRequestedAt,
|
||||
@@ -1351,6 +1361,10 @@ Canonical profile:
|
||||
string? Status,
|
||||
string? Location,
|
||||
string? Salary,
|
||||
decimal? SalaryMin,
|
||||
decimal? SalaryMax,
|
||||
string? SalaryCurrency,
|
||||
string? SalaryPeriod,
|
||||
string? NextAction,
|
||||
DateTime? FollowUpAt,
|
||||
string? Notes,
|
||||
@@ -1369,6 +1383,22 @@ Canonical profile:
|
||||
bool? HasOtherAttachment
|
||||
);
|
||||
|
||||
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
|
||||
decimal? min, decimal? max, string? currency, string? period)
|
||||
{
|
||||
if (min is < 0) min = null;
|
||||
if (max is < 0) max = null;
|
||||
if (min.HasValue && max.HasValue && min > max) (min, max) = (max, min);
|
||||
|
||||
var cur = (currency ?? "").Trim().ToUpperInvariant();
|
||||
if (cur.Length > 8) cur = cur[..8];
|
||||
|
||||
var per = (period ?? "").Trim().ToLowerInvariant();
|
||||
if (per is not ("year" or "month" or "hour")) per = "";
|
||||
|
||||
return (min, max, cur.Length == 0 ? null : cur, per.Length == 0 ? null : per);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<JobApplication>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1377,9 +1407,7 @@ Canonical profile:
|
||||
if (title.Length == 0) return BadRequest("Job title is required.");
|
||||
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
|
||||
|
||||
var companyOk = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
|
||||
if (!companyOk) return BadRequest("companyId does not exist.");
|
||||
|
||||
// Scoped by the Company query filter, so this also rejects another user's companyId.
|
||||
var companyExists = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
|
||||
if (!companyExists) return BadRequest("companyId does not exist.");
|
||||
|
||||
@@ -1388,7 +1416,7 @@ Canonical profile:
|
||||
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
|
||||
JobTitle = title,
|
||||
CompanyId = request.CompanyId,
|
||||
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
|
||||
Status = JobPipeline.Normalize(request.Status),
|
||||
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
|
||||
Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim(),
|
||||
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
|
||||
@@ -1411,6 +1439,9 @@ Canonical profile:
|
||||
ResponseDate = null,
|
||||
};
|
||||
|
||||
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
|
||||
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
|
||||
|
||||
// Generate and persist a short summary at creation time to avoid repeated model calls.
|
||||
try
|
||||
{
|
||||
@@ -1449,6 +1480,10 @@ Canonical profile:
|
||||
DateTime? ResponseDate,
|
||||
string? Location,
|
||||
string? Salary,
|
||||
decimal? SalaryMin,
|
||||
decimal? SalaryMax,
|
||||
string? SalaryCurrency,
|
||||
string? SalaryPeriod,
|
||||
string? NextAction,
|
||||
DateTime? FollowUpAt,
|
||||
bool? HasResume,
|
||||
@@ -1484,11 +1519,13 @@ Canonical profile:
|
||||
|
||||
job.JobTitle = title;
|
||||
job.CompanyId = request.CompanyId;
|
||||
job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : request.Status.Trim();
|
||||
job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : JobPipeline.Normalize(request.Status);
|
||||
job.ResponseReceived = request.ResponseReceived;
|
||||
job.ResponseDate = request.ResponseDate;
|
||||
job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
|
||||
job.Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim();
|
||||
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
|
||||
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
|
||||
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
|
||||
job.FollowUpAt = request.FollowUpAt;
|
||||
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
|
||||
@@ -1535,6 +1572,13 @@ Canonical profile:
|
||||
|
||||
public sealed record UpdateStatusRequest(string Status);
|
||||
|
||||
public sealed record PipelineStageDto(string Key, int Order, string Category);
|
||||
|
||||
/// <summary>Canonical ordered pipeline stages so the UI renders one source of truth.</summary>
|
||||
[HttpGet("pipeline")]
|
||||
public ActionResult<IEnumerable<PipelineStageDto>> GetPipeline()
|
||||
=> Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString())));
|
||||
|
||||
[HttpPatch("{id:int}/status")]
|
||||
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1543,7 +1587,7 @@ Canonical profile:
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required.");
|
||||
var old = job.Status;
|
||||
job.Status = request.Status.Trim();
|
||||
job.Status = JobPipeline.Normalize(request.Status);
|
||||
if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_db.JobEvents.Add(new JobEvent
|
||||
@@ -1560,6 +1604,57 @@ Canonical profile:
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public sealed record StatusSuggestionDto(
|
||||
bool HasSuggestion,
|
||||
string? SuggestedStatus,
|
||||
string? CurrentStatus,
|
||||
string? Signal,
|
||||
string? Confidence,
|
||||
DateTime? MessageDate,
|
||||
string? MessageSubject);
|
||||
|
||||
/// <summary>
|
||||
/// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview
|
||||
/// invite or rejection). Deterministic and always human-confirmed via PATCH .../status.
|
||||
/// </summary>
|
||||
[HttpGet("{id:int}/status-suggestion")]
|
||||
public async Task<ActionResult<StatusSuggestionDto>> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null);
|
||||
|
||||
var latestInbound = await _db.Correspondences
|
||||
.AsNoTracking()
|
||||
.Where(c => c.JobApplicationId == id
|
||||
&& c.Direction != "outbound"
|
||||
&& c.From != "Me")
|
||||
.OrderByDescending(c => c.Date)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (latestInbound is null) return Ok(none);
|
||||
|
||||
var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content);
|
||||
if (suggestion is null) return Ok(none);
|
||||
|
||||
// Don't nag when the job is already in (or past) the suggested stage.
|
||||
var currentOrder = JobPipeline.OrderOf(job.Status);
|
||||
var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus);
|
||||
if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder)
|
||||
{
|
||||
return Ok(none);
|
||||
}
|
||||
|
||||
return Ok(new StatusSuggestionDto(
|
||||
HasSuggestion: true,
|
||||
SuggestedStatus: suggestion.SuggestedStatus,
|
||||
CurrentStatus: job.Status,
|
||||
Signal: suggestion.Signal,
|
||||
Confidence: suggestion.Confidence,
|
||||
MessageDate: latestInbound.Date,
|
||||
MessageSubject: latestInbound.Subject));
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("{id:int}/refresh-ai")]
|
||||
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
||||
@@ -2024,6 +2119,89 @@ Canonical profile:
|
||||
};
|
||||
}
|
||||
|
||||
public sealed record MatchScoreDto(
|
||||
int Score,
|
||||
string Band,
|
||||
int MatchedCount,
|
||||
int TotalKeywords,
|
||||
List<string> MatchedKeywords,
|
||||
List<string> MissingKeywords,
|
||||
List<MatchSectionCoverageDto> SectionCoverage,
|
||||
bool HasEnoughSignal);
|
||||
|
||||
public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total);
|
||||
|
||||
// Builds CV text grouped by section so match coverage can show *where* the evidence sits.
|
||||
private static Dictionary<string, string> BuildCvSections(ApplicationUser? user)
|
||||
{
|
||||
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
|
||||
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void Add(string name, IEnumerable<string?> values)
|
||||
{
|
||||
var text = string.Join("\n", values.Where(v => !string.IsNullOrWhiteSpace(v)));
|
||||
if (!string.IsNullOrWhiteSpace(text)) sections[name] = text;
|
||||
}
|
||||
|
||||
Add("Summary", new[] { structured.Contact.Headline }.Concat(structured.Summary));
|
||||
Add("Skills", structured.Skills);
|
||||
Add("Experience", structured.Jobs.SelectMany(job =>
|
||||
new[] { job.Title, job.Company }.Concat(job.Bullets).Concat(job.Skills)));
|
||||
Add("Education", structured.Education.SelectMany(ed =>
|
||||
new[] { ed.Qualification, ed.Institution }.Concat(ed.Details)));
|
||||
|
||||
// Always include raw profile text (covers users who only pasted plain CV text, and
|
||||
// catches keywords the structured sections missed).
|
||||
if (!string.IsNullOrWhiteSpace(user?.ProfileCvText))
|
||||
{
|
||||
sections["Profile"] = user!.ProfileCvText!;
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fast, deterministic CV↔job keyword coverage score. Unlike candidate-fit (AI narrative),
|
||||
/// this makes no model calls, so it returns instantly and reproducibly.
|
||||
/// </summary>
|
||||
[HttpGet("{id:int}/match-score")]
|
||||
public async Task<ActionResult<MatchScoreDto>> GetMatchScore([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.Include(j => j.Company)
|
||||
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var userId = CurrentUserId;
|
||||
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||
|
||||
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
var cvSections = BuildCvSections(user);
|
||||
if (cvSections.Count == 0)
|
||||
{
|
||||
return BadRequest("Add your profile CV on the Profile page before running the match score.");
|
||||
}
|
||||
|
||||
var jobText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes }
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
if (string.IsNullOrWhiteSpace(jobText))
|
||||
{
|
||||
return BadRequest("This job does not have enough description or notes to compare against your CV.");
|
||||
}
|
||||
|
||||
var result = _matchService.Evaluate(job.JobTitle, jobText, cvSections);
|
||||
|
||||
return Ok(new MatchScoreDto(
|
||||
Score: result.Score,
|
||||
Band: result.Band,
|
||||
MatchedCount: result.MatchedCount,
|
||||
TotalKeywords: result.TotalKeywords,
|
||||
MatchedKeywords: result.MatchedKeywords.ToList(),
|
||||
MissingKeywords: result.MissingKeywords.ToList(),
|
||||
SectionCoverage: result.SectionCoverage.Select(s => new MatchSectionCoverageDto(s.Section, s.Matched, s.Total)).ToList(),
|
||||
HasEnoughSignal: result.HasEnoughSignal));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/candidate-fit")]
|
||||
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user