namespace JobTrackerApi.Services { public enum PipelineCategory { /// /// Pre-application: the user is tracking the opportunity but has not applied yet. /// Nothing in these stages has been submitted, so follow-up/ghosting rules and /// applied-volume analytics must never count them. /// Prospect, Active, Success, Closed, } /// /// How stages collapse on the board. Deliberately NOT the same axis as /// : Category carries analytics semantics (Offer is Success, so /// StageAnalytics excludes it from "how long has this been stuck"), while Group is what the /// user sees (Offer is still something you are actively working, so it groups under Active). /// Collapsing the two would force one to lie. /// public enum PipelineGroup { NotApplied, Active, Closed, } public sealed record PipelineStage(string Key, int Order, PipelineCategory Category, PipelineGroup Group); /// /// 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 { /// /// Fallback for an empty status on the legacy create path, which historically meant /// "already applied". New pre-application flows should pass /// explicitly rather than relying on this. /// public const string DefaultStatus = "Applied"; /// Entry stage for a job captured before the user has applied. public const string SavedStatus = "Saved"; /// /// The detailed internal stages. The board groups these (see ) /// rather than showing ten columns. /// /// Waiting and Ghosted are retained deliberately. Ghosted is where the rules engine parks /// a job that was never answered — it is neither Rejected (nobody rejected you) nor /// Withdrawn (you did not withdraw), and removing it would leave auto-ghosting with no /// target stage. Waiting carries its own follow-up rule and reminder wording. /// public static readonly IReadOnlyList Stages = new List { new("Saved", 1, PipelineCategory.Prospect, PipelineGroup.NotApplied), new("Interested", 2, PipelineCategory.Prospect, PipelineGroup.NotApplied), new("Preparing", 3, PipelineCategory.Prospect, PipelineGroup.NotApplied), new("Applied", 4, PipelineCategory.Active, PipelineGroup.Active), new("Waiting", 5, PipelineCategory.Active, PipelineGroup.Active), new("Interview", 6, PipelineCategory.Active, PipelineGroup.Active), new("Offer", 7, PipelineCategory.Success, PipelineGroup.Active), new("Rejected", 8, PipelineCategory.Closed, PipelineGroup.Closed), new("Ghosted", 9, PipelineCategory.Closed, PipelineGroup.Closed), new("Withdrawn", 10, PipelineCategory.Closed, PipelineGroup.Closed), }; /// Stage keys in a group, in pipeline order. public static IReadOnlyList StagesInGroup(PipelineGroup group) => Stages.Where(s => s.Group == group).OrderBy(s => s.Order).Select(s => s.Key).ToList(); /// True when the status is a pre-application stage (nothing submitted yet). public static bool IsProspect(string? status) { var normalized = Normalize(status); var stage = Stages.FirstOrDefault(s => string.Equals(s.Key, normalized, StringComparison.OrdinalIgnoreCase)); // Unknown custom statuses are NOT treated as prospects: they predate this split and // have always been counted as applied. Assuming otherwise would silently drop them // out of existing users' analytics. return stage?.Category == PipelineCategory.Prospect; } 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) { ["bookmarked"] = "Saved", ["wishlist"] = "Saved", ["to apply"] = "Saved", ["shortlisted"] = "Interested", ["considering"] = "Interested", ["in preparation"] = "Preparing", ["preparing application"] = "Preparing", ["drafting"] = "Preparing", ["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", // "declined" stays mapped to Rejected above (the employer declined you). Withdrawn is // the opposite direction — the user pulled out — so it takes only unambiguous spellings. ["withdrew"] = "Withdrawn", ["cancelled"] = "Withdrawn", ["canceled"] = "Withdrawn", }; /// /// 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()); /// /// Enforces the invariant "DateApplied is set if and only if the job has left the /// pre-application stages". Call after any write to ; /// every status-write path routes through here so they cannot drift apart. /// /// Moving backwards into a Prospect stage clears DateApplied. That is deliberate: the /// alternative — a Saved job still carrying an applied date — silently counts it as /// applied in analytics and exposes it to the follow-up/ghosting rules. /// /// Returns the date that was cleared, or null if nothing was cleared. Callers persist it /// as an (Type = ) so /// the application activity survives the clear — this method has no DbContext, so it /// reports what it did rather than recording it. /// public static DateTime? SyncAppliedDate(Models.JobApplication job, DateTime nowUtc) { if (IsProspect(job.Status)) { var cleared = job.DateApplied; job.DateApplied = null; return cleared; } job.DateApplied ??= nowUtc; return null; } /// /// JobEvent.Type for a DateApplied cleared by a backwards move into a Prospect stage. /// OldValue holds the round-tripped date so the history is machine-readable, not just prose. /// public const string AppliedDateClearedEvent = "AppliedDateCleared"; 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 } } }