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:
cesnimda
2026-07-03 03:31:35 +02:00
parent 209528c8b5
commit bb736d1183
3 changed files with 139 additions and 3 deletions
+54
View File
@@ -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
+75
View File
@@ -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
}
}
}