diff --git a/JobTrackerApi.Tests/JobPipelineTests.cs b/JobTrackerApi.Tests/JobPipelineTests.cs
new file mode 100644
index 0000000..55b1a19
--- /dev/null
+++ b/JobTrackerApi.Tests/JobPipelineTests.cs
@@ -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"));
+ }
+}
diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs
index 03deca7..2062d54 100644
--- a/JobTrackerApi/Controllers/JobApplicationsController.cs
+++ b/JobTrackerApi/Controllers/JobApplicationsController.cs
@@ -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);
+
+ /// Canonical ordered pipeline stages so the UI renders one source of truth.
+ [HttpGet("pipeline")]
+ public ActionResult> GetPipeline()
+ => Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString())));
+
[HttpPatch("{id:int}/status")]
public async Task 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
diff --git a/JobTrackerApi/Services/JobPipeline.cs b/JobTrackerApi/Services/JobPipeline.cs
new file mode 100644
index 0000000..a4ca34e
--- /dev/null
+++ b/JobTrackerApi/Services/JobPipeline.cs
@@ -0,0 +1,75 @@
+namespace JobTrackerApi.Services
+{
+ public enum PipelineCategory
+ {
+ Active,
+ Success,
+ Closed,
+ }
+
+ public sealed record PipelineStage(string Key, int Order, PipelineCategory Category);
+
+ ///
+ /// 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.
+ ///
+ public static class JobPipeline
+ {
+ public const string DefaultStatus = "Applied";
+
+ public static readonly IReadOnlyList Stages = new List
+ {
+ 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 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 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",
+ };
+
+ ///
+ /// 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.
+ ///
+ 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
+ }
+ }
+}