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
@@ -417,6 +417,7 @@ Canonical profile:
JobTitle: job.JobTitle,
Status: job.Status,
DateApplied: job.DateApplied,
SavedAt: job.SavedAt,
ResponseReceived: job.ResponseReceived,
ResponseDate: job.ResponseDate,
Notes: job.Notes,
@@ -754,6 +755,11 @@ Canonical profile:
ResponseDate = null,
};
// A job created straight into a pre-application stage has not been applied to, so it
// must not carry an applied date. SyncAppliedDate also covers the reverse: a create
// that omits DateApplied but names a real stage still gets stamped.
JobPipeline.SyncAppliedDate(job, DateTime.Now);
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
@@ -824,6 +830,8 @@ Canonical profile:
job.CoverLetterText = request.CoverLetterText;
job.JobUrl = NormalizeUrl(request.JobUrl);
if (request.DateApplied is not null) job.DateApplied = request.DateApplied.Value;
// Status may have changed above; keep DateApplied consistent with the stage.
SyncAppliedDateWithHistory(job);
if (oldResponseReceived != job.ResponseReceived || oldResponseDate != job.ResponseDate)
{
@@ -855,7 +863,7 @@ Canonical profile:
/// <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())));
=> Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString(), s.Group.ToString())));
[HttpPatch("{id:int}/status")]
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
@@ -866,6 +874,9 @@ Canonical profile:
if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required.");
var old = job.Status;
job.Status = JobPipeline.Normalize(request.Status);
// Stamps DateApplied when the job leaves the pre-application stages (e.g. the user
// drags Preparing -> Applied), and clears it if they move back.
SyncAppliedDateWithHistory(job);
if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase))
{
_db.JobEvents.Add(new JobEvent
@@ -882,6 +893,33 @@ Canonical profile:
return NoContent();
}
/// <summary>
/// Applies the stage/DateApplied invariant and preserves any discarded application date as
/// a JobEvent, so moving a job backwards into a pre-application stage never destroys the
/// record that it was once applied to. Both update paths route through here rather than
/// calling JobPipeline.SyncAppliedDate directly, so the history cannot be forgotten in one
/// of them.
///
/// Not used by Create: there is no prior state to preserve there, only request
/// normalization.
/// </summary>
private void SyncAppliedDateWithHistory(JobApplication job)
{
var cleared = JobPipeline.SyncAppliedDate(job, DateTime.Now);
if (cleared is null) return;
_db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = JobPipeline.AppliedDateClearedEvent,
// Round-trip format so the date is machine-readable, not just prose.
OldValue = cleared.Value.ToString("o"),
NewValue = null,
Note = $"Moved to {job.Status} before applying; application date cleared.",
At = DateTime.Now,
});
}
/// <summary>
/// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview
/// invite or rejection). Deterministic and always human-confirmed via PATCH .../status.
@@ -1150,9 +1188,11 @@ Canonical profile:
startMonth = endMonth.AddMonths(-months);
}
// DateApplied != null is explicit rather than implied by the range comparison: this is
// applied-volume-per-month, so jobs that have not been applied to must not appear.
var jobs = await _db.JobApplications
.AsNoTracking()
.Where(j => !j.IsDeleted && j.DateApplied >= startMonth && j.DateApplied < endMonth)
.Where(j => !j.IsDeleted && j.DateApplied != null && j.DateApplied >= startMonth && j.DateApplied < endMonth)
.Select(j => new { j.DateApplied, j.ResponseDate })
.ToListAsync(cancellationToken);
@@ -1163,7 +1203,7 @@ Canonical profile:
foreach (var j in jobs)
{
var ak = Key(j.DateApplied);
var ak = Key(j.DateApplied!.Value);
applied[ak] = (applied.TryGetValue(ak, out var av) ? av : 0) + 1;
if (j.ResponseDate is not null)
@@ -2139,7 +2179,7 @@ Candidate master CV:
var subject = BuildFollowUpSubject(job, lastMessage);
var reference = lastMessage?.Subject ?? job.JobTitle;
var summary = job.ShortSummary;
var appliedDate = job.DateApplied.ToString("MMMM d, yyyy");
var appliedDate = job.DateApplied?.ToString("MMMM d, yyyy") ?? "not yet applied";
var tagHighlights = SplitTags(job.Tags).Take(4).ToList();
var companyName = job.Company?.Name ?? "your team";
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);