feat: Phase 0 foundation — Job entity, expanded pipeline, AI service lockdown, DateApplied history

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>
This commit is contained in:
cesnimda
2026-07-17 17:05:25 +02:00
parent b176a44627
commit eac34705e3
36 changed files with 3060 additions and 96 deletions
+12 -5
View File
@@ -45,11 +45,13 @@ namespace JobTrackerApi.Services
// ponytail: average age needs a per-row day-diff that doesn't translate identically
// across the SQLite/MySQL providers this app runs on, so pull just the DateApplied
// column (no wide blob columns) for active rows and average client-side.
// DateApplied is null for pre-application stages; those have no "days since applied"
// and are filtered out server-side so they can't drag the average toward zero.
var activeDates = active == 0
? new List<DateTime>()
: await _db.JobApplications.AsNoTracking()
.Where(j => !j.IsDeleted)
.Select(j => j.DateApplied)
.Where(j => !j.IsDeleted && j.DateApplied != null)
.Select(j => j.DateApplied!.Value)
.ToListAsync(cancellationToken);
var avgDays = activeDates.Count == 0
@@ -80,6 +82,7 @@ namespace JobTrackerApi.Services
j.ResponseReceived,
j.ResponseDate,
j.DateApplied,
j.SavedAt,
j.CompanyId,
CompanyName = j.Company.Name,
CompanySource = j.Company.Source
@@ -122,9 +125,11 @@ namespace JobTrackerApi.Services
.Take(8)
.ToList();
// "Days to respond" is only meaningful once applied, so rows with no DateApplied
// (pre-application stages) are excluded rather than measured from nothing.
var responseDays = activeJobs
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null && j.DateApplied is not null)
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied!.Value).TotalDays))
.OrderBy(x => x)
.ToList();
@@ -153,7 +158,9 @@ namespace JobTrackerApi.Services
var occupancy = activeJobs.Select(job =>
{
var current = JobPipeline.Normalize(job.Status);
DateTime enteredAt = job.DateApplied;
// Fall back to SavedAt when the job has not been applied to: every job has a
// saved date, so a stage entry time always exists even before DateApplied does.
DateTime enteredAt = job.DateApplied ?? job.SavedAt;
if (lastEntryByJob.TryGetValue(job.Id, out var changes))
{
var lastIntoCurrent = changes
@@ -92,7 +92,9 @@ public sealed class FollowUpReminderHostedService : BackgroundService
var followMode = SuggestFollowUpMode(job.Status);
var detailsUrl = $"{baseUrl}/jobs?open={job.Id}&tab=4&followMode={Uri.EscapeDataString(followMode)}";
var companyName = job.Company?.Name ?? "Unknown company";
var appliedOn = job.DateApplied.ToString("MMMM d, yyyy");
// RulesEngine never raises a follow-up for a job with no DateApplied, so this should
// always have a value; the fallback just keeps the email readable rather than throwing.
var appliedOn = job.DateApplied?.ToString("MMMM d, yyyy") ?? "an unrecorded date";
var subject = $"Follow up reminder: {job.JobTitle} at {companyName}";
var body = string.Join("\n\n", new[]
{
+109 -7
View File
@@ -2,12 +2,32 @@ 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,
}
public sealed record PipelineStage(string Key, int Order, PipelineCategory Category);
/// <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
@@ -17,24 +37,68 @@ namespace JobTrackerApi.Services
/// </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("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),
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",
@@ -46,6 +110,11 @@ namespace JobTrackerApi.Services
["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>
@@ -65,6 +134,39 @@ namespace JobTrackerApi.Services
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);
+11 -2
View File
@@ -47,9 +47,14 @@ namespace JobTrackerApi.Services
var status = job.Status ?? "Applied";
if (status == "Interviewing") status = "Interview";
// Nothing has been submitted in a pre-application stage, so there is nobody to chase
// and nobody to be ghosted by. Guard before any date maths: a Saved job has no
// DateApplied, and treating that as "very old" would silently auto-ghost it.
if (JobPipeline.IsProspect(status)) return new FollowUpDecision(false, null, false);
// Last activity: any explicit follow-up date, response date, feedback request, or correspondence message.
var last = Max(
job.DateApplied,
job.DateApplied ?? job.SavedAt,
job.ResponseDate,
job.FollowUpAt,
job.FeedbackRequestedAt,
@@ -61,7 +66,11 @@ namespace JobTrackerApi.Services
// Applied: if no response and enough time passed since applied.
if (string.Equals(status, "Applied", StringComparison.OrdinalIgnoreCase) && !job.ResponseReceived)
{
var daysSinceApplied = (now - job.DateApplied).TotalDays;
// An Applied job should always have DateApplied. Fail safe rather than fall back to
// a synthetic date, which could ghost the job on the next rules pass.
if (job.DateApplied is null) return new FollowUpDecision(false, null, false);
var daysSinceApplied = (now - job.DateApplied.Value).TotalDays;
if (daysSinceApplied >= s.AppliedFollowUpDays)
return new FollowUpDecision(true, $"No reply after {s.AppliedFollowUpDays}d", daysSinceApplied >= s.AppliedGhostDays);
return new FollowUpDecision(false, null, daysSinceApplied >= s.AppliedGhostDays);