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,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