eac34705e3
Unblocks the documented core workflow and closes the AI-service exposure, without changing existing behaviour. Job/JobApplication split (additive; see ADR-002): - New Job entity (the opportunity) with owner-scoped query filter; nullable JobApplication.JobId FK. Nothing reads Job yet. - Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned tables the scaffolder re-emitted; verified against the real dev DB. Pipeline: 10 internal stages across three concerns kept separate — PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) / PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn; keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage chip and full transitions; drag applies only safe transitions (never infers Ghosted/Withdrawn). DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay accurate; the discarded date is preserved as an AppliedDateCleared JobEvent. AI service lockdown: no host port; private ai_internal network (backend is the only other member); X-Ai-Service-Token required on all non-/health endpoints; AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live stack. Also carries two pre-existing working-tree files (views/ProfilePage.tsx, views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration. Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
8.5 KiB
C#
178 lines
8.5 KiB
C#
namespace JobTrackerApi.Services
|
|
{
|
|
public enum PipelineCategory
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
Prospect,
|
|
Active,
|
|
Success,
|
|
Closed,
|
|
}
|
|
|
|
/// <summary>
|
|
/// How stages collapse on the board. Deliberately NOT the same axis as
|
|
/// <see cref="PipelineCategory"/>: 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.
|
|
/// </summary>
|
|
public enum PipelineGroup
|
|
{
|
|
NotApplied,
|
|
Active,
|
|
Closed,
|
|
}
|
|
|
|
public sealed record PipelineStage(string Key, int Order, PipelineCategory Category, PipelineGroup Group);
|
|
|
|
/// <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
|
|
{
|
|
/// <summary>
|
|
/// Fallback for an empty status on the legacy create path, which historically meant
|
|
/// "already applied". New pre-application flows should pass <see cref="SavedStatus"/>
|
|
/// explicitly rather than relying on this.
|
|
/// </summary>
|
|
public const string DefaultStatus = "Applied";
|
|
|
|
/// <summary>Entry stage for a job captured before the user has applied.</summary>
|
|
public const string SavedStatus = "Saved";
|
|
|
|
/// <summary>
|
|
/// The detailed internal stages. The board groups these (see <see cref="PipelineGroup"/>)
|
|
/// 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.
|
|
/// </summary>
|
|
public static readonly IReadOnlyList<PipelineStage> Stages = new List<PipelineStage>
|
|
{
|
|
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),
|
|
};
|
|
|
|
/// <summary>Stage keys in a group, in pipeline order.</summary>
|
|
public static IReadOnlyList<string> StagesInGroup(PipelineGroup group)
|
|
=> Stages.Where(s => s.Group == group).OrderBy(s => s.Order).Select(s => s.Key).ToList();
|
|
|
|
/// <summary>True when the status is a pre-application stage (nothing submitted yet).</summary>
|
|
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<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)
|
|
{
|
|
["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",
|
|
};
|
|
|
|
/// <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());
|
|
|
|
/// <summary>
|
|
/// Enforces the invariant "DateApplied is set if and only if the job has left the
|
|
/// pre-application stages". Call after any write to <see cref="JobApplication.Status"/>;
|
|
/// 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 <see cref="Models.JobEvent"/> (Type = <see cref="AppliedDateClearedEvent"/>) so
|
|
/// the application activity survives the clear — this method has no DbContext, so it
|
|
/// reports what it did rather than recording it.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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
|
|
}
|
|
}
|
|
}
|