feat: canonical job pipeline as single source of truth
New JobPipeline: ordered canonical stages (Applied, Waiting, Interview, Offer, Rejected, Ghosted) with category grouping and a Normalize() that canonicalizes casing and known synonyms (Interviewing->Interview, declined->Rejected, ...) while preserving unknown custom statuses. - normalize status on every write path (Create/Update/PATCH status) so the stored value stays canonical without destroying custom values - GET /api/jobapplications/pipeline exposes the ordered stages so the UI renders from one source instead of duplicated hardcoded lists - 14 unit tests; full backend suite green (120) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
using JobTrackerApi.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class JobPipelineTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("applied", "Applied")]
|
||||
[InlineData("APPLIED", "Applied")]
|
||||
[InlineData(" Offer ", "Offer")]
|
||||
[InlineData("Interviewing", "Interview")]
|
||||
[InlineData("interviews", "Interview")]
|
||||
[InlineData("declined", "Rejected")]
|
||||
[InlineData("no response", "Ghosted")]
|
||||
public void Normalize_canonicalizes_casing_and_synonyms(string input, string expected)
|
||||
=> Assert.Equal(expected, JobPipeline.Normalize(input));
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData(null)]
|
||||
public void Normalize_empty_becomes_default(string? input)
|
||||
=> Assert.Equal("Applied", JobPipeline.Normalize(input));
|
||||
|
||||
[Fact]
|
||||
public void Normalize_preserves_unknown_custom_status()
|
||||
=> Assert.Equal("Take-home assignment", JobPipeline.Normalize(" Take-home assignment "));
|
||||
|
||||
[Fact]
|
||||
public void Stages_are_ordered_and_unique()
|
||||
{
|
||||
var orders = JobPipeline.Stages.Select(s => s.Order).ToList();
|
||||
Assert.Equal(orders.OrderBy(x => x), orders);
|
||||
Assert.Equal(orders.Count, orders.Distinct().Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OrderOf_sorts_canonical_before_custom()
|
||||
{
|
||||
Assert.True(JobPipeline.OrderOf("Applied") < JobPipeline.OrderOf("Offer"));
|
||||
Assert.True(JobPipeline.OrderOf("Offer") < JobPipeline.OrderOf("Custom stage"));
|
||||
Assert.Equal(JobPipeline.OrderOf("Interview"), JobPipeline.OrderOf("Interviewing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsCanonical_only_true_for_known_stages()
|
||||
{
|
||||
Assert.True(JobPipeline.IsCanonical("Offer"));
|
||||
Assert.True(JobPipeline.IsCanonical("offer"));
|
||||
Assert.False(JobPipeline.IsCanonical("Interviewing")); // synonym, not canonical
|
||||
Assert.False(JobPipeline.IsCanonical("Whatever"));
|
||||
}
|
||||
}
|
||||
@@ -1416,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(),
|
||||
@@ -1519,7 +1519,7 @@ 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();
|
||||
@@ -1572,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)
|
||||
{
|
||||
@@ -1580,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
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
public enum PipelineCategory
|
||||
{
|
||||
Active,
|
||||
Success,
|
||||
Closed,
|
||||
}
|
||||
|
||||
public sealed record PipelineStage(string Key, int Order, PipelineCategory Category);
|
||||
|
||||
/// <summary>
|
||||
/// Canonical job-application pipeline: the single source of truth for the ordered set of
|
||||
/// statuses, their grouping, and how free-text/legacy values normalize onto them.
|
||||
/// Status remains a free-text column so custom values are never destroyed; this only
|
||||
/// canonicalizes casing and known synonyms.
|
||||
/// </summary>
|
||||
public static class JobPipeline
|
||||
{
|
||||
public const string DefaultStatus = "Applied";
|
||||
|
||||
public static readonly IReadOnlyList<PipelineStage> Stages = new List<PipelineStage>
|
||||
{
|
||||
new("Applied", 1, PipelineCategory.Active),
|
||||
new("Waiting", 2, PipelineCategory.Active),
|
||||
new("Interview", 3, PipelineCategory.Active),
|
||||
new("Offer", 4, PipelineCategory.Success),
|
||||
new("Rejected", 5, PipelineCategory.Closed),
|
||||
new("Ghosted", 6, PipelineCategory.Closed),
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> Canonical =
|
||||
Stages.ToDictionary(s => s.Key, s => s.Key, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Legacy/synonym spellings that should collapse onto a canonical stage.
|
||||
private static readonly Dictionary<string, string> Aliases = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["interviewing"] = "Interview",
|
||||
["interviews"] = "Interview",
|
||||
["interviewed"] = "Interview",
|
||||
["in interview"] = "Interview",
|
||||
["awaiting response"] = "Waiting",
|
||||
["awaiting"] = "Waiting",
|
||||
["in progress"] = "Waiting",
|
||||
["pending"] = "Waiting",
|
||||
["no response"] = "Ghosted",
|
||||
["no reply"] = "Ghosted",
|
||||
["declined"] = "Rejected",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the canonical status for a raw value: trims, matches a stage case-insensitively,
|
||||
/// or maps a known synonym. Unknown non-empty values are preserved (trimmed) so custom
|
||||
/// statuses survive. Empty/whitespace becomes the default stage.
|
||||
/// </summary>
|
||||
public static string Normalize(string? status)
|
||||
{
|
||||
var trimmed = (status ?? string.Empty).Trim();
|
||||
if (trimmed.Length == 0) return DefaultStatus;
|
||||
if (Canonical.TryGetValue(trimmed, out var canonical)) return canonical;
|
||||
if (Aliases.TryGetValue(trimmed, out var alias)) return alias;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
public static bool IsCanonical(string? status)
|
||||
=> !string.IsNullOrWhiteSpace(status) && Canonical.ContainsKey(status.Trim());
|
||||
|
||||
public static int OrderOf(string? status)
|
||||
{
|
||||
var normalized = Normalize(status);
|
||||
var stage = Stages.FirstOrDefault(s => string.Equals(s.Key, normalized, StringComparison.OrdinalIgnoreCase));
|
||||
return stage?.Order ?? int.MaxValue; // custom statuses sort last
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user