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 + } + } +}