From ba7748193b9592fb246bffc722cbad7ec01d0a6e Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 31 Aug 2026 11:59:49 +0200 Subject: [PATCH] refactor(jobs): centralize status lifecycle --- .../JobApplicationsAppliedDateHistoryTests.cs | 17 +--- .../JobApplicationsAuthorizationTests.cs | 3 +- .../JobApplicationsEndpointBehaviorTests.cs | 4 +- .../JobApplicationLifecycleController.cs | 63 +++++++++++++ .../Controllers/JobApplicationsController.cs | 90 +------------------ JobTrackerApi/Services/JobLifecycleEvents.cs | 20 +++++ docs/work-programmes/master-progress.md | 2 + 7 files changed, 92 insertions(+), 107 deletions(-) diff --git a/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs b/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs index 14c5d67..be3ada3 100644 --- a/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsAppliedDateHistoryTests.cs @@ -113,21 +113,8 @@ public sealed class JobApplicationsAppliedDateHistoryTests return job; } - private static JobApplicationsController CreateController(JobTrackerContext db, string userId) - { - var controller = new JobApplicationsController(db, Mock.Of(), 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 JobApplicationLifecycleController CreateController(JobTrackerContext db, string userId) + => new(db); private static JobTrackerContext CreateDb() => TestHostFactory.CreateInMemoryDb(); } diff --git a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs index 96a98f8..65d7cf5 100644 --- a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs @@ -69,7 +69,8 @@ public sealed class JobApplicationsAuthorizationTests var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync(); 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(result.Result); } diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index 5068421..c94f432 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -79,7 +79,7 @@ public sealed class JobApplicationsEndpointBehaviorTests }); await db.SaveChangesAsync(); - var controller = CreateController(db, "user-1"); + var controller = new JobApplicationLifecycleController(db); var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None); var ok = Assert.IsType(result.Result); @@ -110,7 +110,7 @@ public sealed class JobApplicationsEndpointBehaviorTests }); await db.SaveChangesAsync(); - var controller = CreateController(db, "user-1"); + var controller = new JobApplicationLifecycleController(db); var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None); var ok = Assert.IsType(result.Result); diff --git a/JobTrackerApi/Controllers/JobApplicationLifecycleController.cs b/JobTrackerApi/Controllers/JobApplicationLifecycleController.cs index c3dee93..fc7f13a 100644 --- a/JobTrackerApi/Controllers/JobApplicationLifecycleController.cs +++ b/JobTrackerApi/Controllers/JobApplicationLifecycleController.cs @@ -28,6 +28,69 @@ public sealed class JobApplicationLifecycleController : ControllerBase stage.Category.ToString(), stage.Group.ToString()))); + [HttpPatch("{id:int}/status")] + public async Task 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(); + } + + /// + /// Suggests a pipeline status from the job's latest inbound message. Applying it remains a + /// separate, user-confirmed status update. + /// + [HttpGet("{id:int}/status-suggestion")] + public async Task> 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}")] public async Task SoftDelete([FromRoute] int id, CancellationToken cancellationToken) { diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 409bef0..69bd4e2 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -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 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(); - } - - /// - /// 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. - /// - 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, - }); - } - - /// - /// 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. - /// - [HttpGet("{id:int}/status-suggestion")] - public async Task> 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> RefreshAi([FromRoute] int id, CancellationToken cancellationToken) diff --git a/JobTrackerApi/Services/JobLifecycleEvents.cs b/JobTrackerApi/Services/JobLifecycleEvents.cs index b6d5a63..cf81ea6 100644 --- a/JobTrackerApi/Services/JobLifecycleEvents.cs +++ b/JobTrackerApi/Services/JobLifecycleEvents.cs @@ -14,6 +14,26 @@ namespace JobTrackerApi.Services; // docs/architecture/application-workspace.md. public static class JobLifecycleEvents { + /// + /// 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. + /// + 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. // Replaces the hand-written StatusChanged block at each call site. public static void RecordStatusChange(JobTrackerContext db, JobApplication job, string? oldStatus, DateTime at) diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 5f3a21c..6625f00 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -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. - 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. +- 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. - 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. @@ -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. - 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. +- 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. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed.