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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user