Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features #1
@@ -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,
|
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
|
||||||
JobTitle = title,
|
JobTitle = title,
|
||||||
CompanyId = request.CompanyId,
|
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(),
|
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
|
||||||
Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim(),
|
Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim(),
|
||||||
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
|
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
|
||||||
@@ -1519,7 +1519,7 @@ Canonical profile:
|
|||||||
|
|
||||||
job.JobTitle = title;
|
job.JobTitle = title;
|
||||||
job.CompanyId = request.CompanyId;
|
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.ResponseReceived = request.ResponseReceived;
|
||||||
job.ResponseDate = request.ResponseDate;
|
job.ResponseDate = request.ResponseDate;
|
||||||
job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
|
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 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")]
|
[HttpPatch("{id:int}/status")]
|
||||||
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
|
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.");
|
if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required.");
|
||||||
var old = job.Status;
|
var old = job.Status;
|
||||||
job.Status = request.Status.Trim();
|
job.Status = JobPipeline.Normalize(request.Status);
|
||||||
if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_db.JobEvents.Add(new JobEvent
|
_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