refactor(jobs): centralize status lifecycle
This commit is contained in:
@@ -867,7 +867,7 @@ Canonical profile:
|
||||
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);
|
||||
JobLifecycleEvents.SyncAppliedDateWithHistory(_db, job, DateTime.Now);
|
||||
|
||||
// The persisted short analysis is generated on create. Refresh it only when the source
|
||||
// job content materially changes; opening the workspace or saving an unrelated field
|
||||
@@ -955,94 +955,6 @@ Canonical profile:
|
||||
return string.Join(' ', words);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:int}/status")]
|
||||
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
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);
|
||||
JobLifecycleEvents.RecordStatusChange(_db, job, old, DateTime.Now);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
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.
|
||||
/// </summary>
|
||||
[HttpGet("{id:int}/status-suggestion")]
|
||||
public async Task<ActionResult<StatusSuggestionDto>> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications.AsNoTracking().FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
|
||||
var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null);
|
||||
|
||||
var latestInbound = await _db.Correspondences
|
||||
.AsNoTracking()
|
||||
.Where(c => c.JobApplicationId == id
|
||||
&& c.Direction != "outbound"
|
||||
&& c.From != "Me")
|
||||
.OrderByDescending(c => c.Date)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (latestInbound is null) return Ok(none);
|
||||
|
||||
var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content);
|
||||
if (suggestion is null) return Ok(none);
|
||||
|
||||
// Don't nag when the job is already in (or past) the suggested stage.
|
||||
var currentOrder = JobPipeline.OrderOf(job.Status);
|
||||
var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus);
|
||||
if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder)
|
||||
{
|
||||
return Ok(none);
|
||||
}
|
||||
|
||||
return Ok(new StatusSuggestionDto(
|
||||
HasSuggestion: true,
|
||||
SuggestedStatus: suggestion.SuggestedStatus,
|
||||
CurrentStatus: job.Status,
|
||||
Signal: suggestion.Signal,
|
||||
Confidence: suggestion.Confidence,
|
||||
MessageDate: latestInbound.Date,
|
||||
MessageSubject: latestInbound.Subject));
|
||||
}
|
||||
|
||||
|
||||
[HttpPost("{id:int}/refresh-ai")]
|
||||
[Authorize(Policy = ProEntitlement.Policy)]
|
||||
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
||||
|
||||
Reference in New Issue
Block a user