refactor(jobs): centralize status lifecycle
This commit is contained in:
@@ -113,21 +113,8 @@ public sealed class JobApplicationsAppliedDateHistoryTests
|
|||||||
return job;
|
return job;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
|
private static JobApplicationLifecycleController CreateController(JobTrackerContext db, string userId)
|
||||||
{
|
=> new(db);
|
||||||
var controller = new JobApplicationsController(db, Mock.Of<ISummarizerService>(), TestHostFactory.CreateUserManager().Object);
|
|
||||||
controller.ControllerContext = new ControllerContext
|
|
||||||
{
|
|
||||||
HttpContext = new DefaultHttpContext
|
|
||||||
{
|
|
||||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
|
||||||
{
|
|
||||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
|
||||||
}, "test"))
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return controller;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static JobTrackerContext CreateDb() => TestHostFactory.CreateInMemoryDb();
|
private static JobTrackerContext CreateDb() => TestHostFactory.CreateInMemoryDb();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,8 @@ public sealed class JobApplicationsAuthorizationTests
|
|||||||
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
|
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
|
||||||
|
|
||||||
await using var attackerDb = CreateDb(dbName, "other-user");
|
await using var attackerDb = CreateDb(dbName, "other-user");
|
||||||
var result = await CreateController(attackerDb).GetStatusSuggestion(jobId, CancellationToken.None);
|
var result = await new JobApplicationLifecycleController(attackerDb)
|
||||||
|
.GetStatusSuggestion(jobId, CancellationToken.None);
|
||||||
|
|
||||||
Assert.IsType<NotFoundResult>(result.Result);
|
Assert.IsType<NotFoundResult>(result.Result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var controller = CreateController(db, "user-1");
|
var controller = new JobApplicationLifecycleController(db);
|
||||||
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
@@ -110,7 +110,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var controller = CreateController(db, "user-1");
|
var controller = new JobApplicationLifecycleController(db);
|
||||||
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
|||||||
@@ -28,6 +28,69 @@ public sealed class JobApplicationLifecycleController : ControllerBase
|
|||||||
stage.Category.ToString(),
|
stage.Category.ToString(),
|
||||||
stage.Group.ToString())));
|
stage.Group.ToString())));
|
||||||
|
|
||||||
|
[HttpPatch("{id:int}/status")]
|
||||||
|
public async Task<IActionResult> UpdateStatus(
|
||||||
|
[FromRoute] int id,
|
||||||
|
[FromBody] UpdateStatusRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required.");
|
||||||
|
|
||||||
|
var oldStatus = job.Status;
|
||||||
|
var changedAt = DateTime.Now;
|
||||||
|
job.Status = JobPipeline.Normalize(request.Status);
|
||||||
|
JobLifecycleEvents.SyncAppliedDateWithHistory(_db, job, changedAt);
|
||||||
|
JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, changedAt);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggests a pipeline status from the job's latest inbound message. Applying it remains a
|
||||||
|
/// separate, user-confirmed status update.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id:int}/status-suggestion")]
|
||||||
|
public async Task<ActionResult<StatusSuggestionDto>> GetStatusSuggestion(
|
||||||
|
[FromRoute] int id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(item => item.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(item => item.JobApplicationId == id
|
||||||
|
&& item.Direction != "outbound"
|
||||||
|
&& item.From != "Me")
|
||||||
|
.OrderByDescending(item => item.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);
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
[HttpDelete("{id:int}")]
|
||||||
public async Task<IActionResult> SoftDelete([FromRoute] int id, CancellationToken cancellationToken)
|
public async Task<IActionResult> SoftDelete([FromRoute] int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -867,7 +867,7 @@ Canonical profile:
|
|||||||
job.JobUrl = NormalizeUrl(request.JobUrl);
|
job.JobUrl = NormalizeUrl(request.JobUrl);
|
||||||
if (request.DateApplied is not null) job.DateApplied = request.DateApplied.Value;
|
if (request.DateApplied is not null) job.DateApplied = request.DateApplied.Value;
|
||||||
// Status may have changed above; keep DateApplied consistent with the stage.
|
// 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
|
// 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
|
// job content materially changes; opening the workspace or saving an unrelated field
|
||||||
@@ -955,94 +955,6 @@ Canonical profile:
|
|||||||
return string.Join(' ', words);
|
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")]
|
[HttpPost("{id:int}/refresh-ai")]
|
||||||
[Authorize(Policy = ProEntitlement.Policy)]
|
[Authorize(Policy = ProEntitlement.Policy)]
|
||||||
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -14,6 +14,26 @@ namespace JobTrackerApi.Services;
|
|||||||
// docs/architecture/application-workspace.md.
|
// docs/architecture/application-workspace.md.
|
||||||
public static class JobLifecycleEvents
|
public static class JobLifecycleEvents
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps the application date aligned with the pipeline stage and preserves a cleared date in
|
||||||
|
/// the event ledger. Both full-record and status-only updates use this single boundary.
|
||||||
|
/// </summary>
|
||||||
|
public static void SyncAppliedDateWithHistory(JobTrackerContext db, JobApplication job, DateTime at)
|
||||||
|
{
|
||||||
|
var cleared = JobPipeline.SyncAppliedDate(job, at);
|
||||||
|
if (cleared is null) return;
|
||||||
|
|
||||||
|
db.JobEvents.Add(new JobEvent
|
||||||
|
{
|
||||||
|
JobApplicationId = job.Id,
|
||||||
|
Type = JobPipeline.AppliedDateClearedEvent,
|
||||||
|
OldValue = cleared.Value.ToString("o"),
|
||||||
|
NewValue = null,
|
||||||
|
Note = $"Moved to {job.Status} before applying; application date cleared.",
|
||||||
|
At = at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Records the status change itself plus, when the transition warrants it, one lifecycle event.
|
// Records the status change itself plus, when the transition warrants it, one lifecycle event.
|
||||||
// Replaces the hand-written StatusChanged block at each call site.
|
// Replaces the hand-written StatusChanged block at each call site.
|
||||||
public static void RecordStatusChange(JobTrackerContext db, JobApplication job, string? oldStatus, DateTime at)
|
public static void RecordStatusChange(JobTrackerContext db, JobApplication job, string? oldStatus, DateTime at)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ Updated: 2026-08-30
|
|||||||
- Continued the job-application controller split by moving canonical pipeline metadata, soft delete/restore, follow-up scheduling, and event history into `JobApplicationLifecycleController` without changing routes, authorization, tenant filtering, response shapes, or event behavior. Status mutation remains with core updates until its shared applied-date invariant has a single service owner.
|
- Continued the job-application controller split by moving canonical pipeline metadata, soft delete/restore, follow-up scheduling, and event history into `JobApplicationLifecycleController` without changing routes, authorization, tenant filtering, response shapes, or event behavior. Status mutation remains with core updates until its shared applied-date invariant has a single service owner.
|
||||||
- Added a minimal public `/ready` dependency probe alongside the existing `/health` liveness probe. Nginx exposes both, deployment validation now checks the frontend, API liveness, database readiness, and public auth configuration separately, while detailed dependency metadata remains restricted to Admin/System.
|
- Added a minimal public `/ready` dependency probe alongside the existing `/health` liveness probe. Nginx exposes both, deployment validation now checks the frontend, API liveness, database readiness, and public auth configuration separately, while detailed dependency metadata remains restricted to Admin/System.
|
||||||
- Hardened deployment replacement semantics: backend/frontend images receive exact commit tags, the prior running core images are retained as the rollback release, and any post-replacement failure automatically restores both previous services while keeping the deployment result failed. Removed the non-actionable blanket `compose pull || true` suppression.
|
- Hardened deployment replacement semantics: backend/frontend images receive exact commit tags, the prior running core images are retained as the rollback release, and any post-replacement failure automatically restores both previous services while keeping the deployment result failed. Removed the non-actionable blanket `compose pull || true` suppression.
|
||||||
|
- Completed the next lifecycle-controller boundary by moving status changes and deterministic email status suggestions out of the core controller. The applied-date invariant and cleared-date audit event now live in `JobLifecycleEvents`, so full edits and status-only updates cannot drift.
|
||||||
- Transferred the independent `UserRuleSettings` table from both provider startup paths to its own provider-aware migration; owner-keyed settings survive adoption, downgrade and retry.
|
- Transferred the independent `UserRuleSettings` table from both provider startup paths to its own provider-aware migration; owner-keyed settings survive adoption, downgrade and retry.
|
||||||
- Moved `GmailReviewDecisions` into a provider-aware migration, preserving existing SQLite decisions and closing the previously missing MariaDB table path.
|
- Moved `GmailReviewDecisions` into a provider-aware migration, preserving existing SQLite decisions and closing the previously missing MariaDB table path.
|
||||||
- Moved recovery codes, trusted devices, and revocable user sessions into one provider-aware authentication-support migration; populated legacy rows and indexes survive adoption, downgrade, and retry.
|
- Moved recovery codes, trusted devices, and revocable user sessions into one provider-aware authentication-support migration; populated legacy rows and indexes survive adoption, downgrade, and retry.
|
||||||
@@ -98,6 +99,7 @@ Updated: 2026-08-30
|
|||||||
- Lifecycle-controller extraction: locked restore passed, Release build passed with 0 warnings/errors, and the complete backend suite passed 736/736.
|
- Lifecycle-controller extraction: locked restore passed, Release build passed with 0 warnings/errors, and the complete backend suite passed 736/736.
|
||||||
- Health/readiness split: Release build passed with 0 warnings/errors, the complete backend suite passed 736/736, and the focused Playwright probe passed 1/1 against a real disposable SQLite-backed API process.
|
- Health/readiness split: Release build passed with 0 warnings/errors, the complete backend suite passed 736/736, and the focused Playwright probe passed 1/1 against a real disposable SQLite-backed API process.
|
||||||
- Deployment rollback configuration: Docker Compose configuration validation passed with non-secret fixture values. Runtime rollback rehearsal remains pending because the local Linux Docker daemon is offline.
|
- Deployment rollback configuration: Docker Compose configuration validation passed with non-secret fixture values. Runtime rollback rehearsal remains pending because the local Linux Docker daemon is offline.
|
||||||
|
- Status lifecycle extraction: Release build passed with 0 warnings/errors and the complete backend suite passed 736/736, including applied-date preservation, status suggestions, and cross-tenant not-found behavior.
|
||||||
- Focused frontend: 2 suites, 6 tests passed.
|
- Focused frontend: 2 suites, 6 tests passed.
|
||||||
- Full frontend: 64 suites, 272 tests passed.
|
- Full frontend: 64 suites, 272 tests passed.
|
||||||
- Next production build and TypeScript: passed.
|
- Next production build and TypeScript: passed.
|
||||||
|
|||||||
Reference in New Issue
Block a user