diff --git a/BLOCKERS.md b/BLOCKERS.md index 12811a1..354041d 100644 --- a/BLOCKERS.md +++ b/BLOCKERS.md @@ -1,6 +1,6 @@ # Blockers -Updated: 2026-08-15 +Updated: 2026-08-31 ## Stripe billing @@ -26,7 +26,7 @@ Updated: 2026-08-15 - **Required:** After the current pull request passes CI and is approved, follow `docs/release-candidate-review.md` and `docs/release-checklist.md` on the production host. Confirm the admin-only version badge matches the deployed commit, then run the authenticated application workspace, Career, CV, attachment, email-verification and rollback checks. - **Recommended:** Verify backup/restore before deployment, then exercise login, existing application counts, Career Workspace, public CV refresh/download, AI, and attachments in order. - **Current access check:** Read-only SSH access is confirmed. All four JobTracker containers are healthy with zero observed restarts, but root free space is now 36 GiB (83% used). The production checkout is at `de937d25dc5e` / app version `157` and has an unreviewed mode-only change to `deploy/deploy.sh`. No production change or deployment was attempted. -- **Current status:** PR 28 includes the current release-readiness work; current remote CI still needs confirmation. The local release matrix includes backend 680/680, frontend 237/237, build and Chromium 9/9. A disposable MariaDB 11.8 fresh/restart rehearsal now passes all 29 migrations with 49 tables and provider-correct sampled types; this does not replace the required backup/restore and production rollout rehearsal. Read-only PROD-001 inventory found the JobTracker Ollama and frontend published on all host interfaces, the newest gzip-valid MariaDB backup dated 2026-08-02, no observed scheduled JobTracker backup, and no owner-file/key/tombstone recovery bundle. Close these rollout gates before deployment; see `docs/production/production-ai-hardware-assessment.md`. +- **Current status:** On 2026-08-31 the anonymous public liveness endpoint `https://jobs.cesnimda.uk/health` returned HTTP 200 with version `276`, and the Gitea host returned HTTP 200; the earlier public 502 is no longer present. Anonymous `https://jobs.cesnimda.uk/api/health` returned HTTP 401 as expected for an authenticated API route. This proves the proxy/frontend liveness path only, not authenticated application behavior or database readiness. Gitea run 696 failed after repeated truncated Playwright downloads and then a runner-level exit 139 before any test assertion; the workflow now installs through the lockfile CLI and retries only that transient process failure without hiding real test failures. Current remote CI, backup/restore, authenticated smoke and rollback still require operator-controlled environments. Read-only PROD-001 inventory found the JobTracker Ollama and frontend published on all host interfaces, the newest gzip-valid MariaDB backup dated 2026-08-02, no observed scheduled JobTracker backup, and no owner-file/key/tombstone recovery bundle. Close these rollout gates before deployment; see `docs/production/production-ai-hardware-assessment.md`. ## Account deletion retention and restore policy diff --git a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs index d0010e9..96a98f8 100644 --- a/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsAuthorizationTests.cs @@ -31,7 +31,7 @@ public sealed class JobApplicationsAuthorizationTests await ownerDb.SaveChangesAsync(); await using var attackerDb = CreateDb(dbName, "other-user"); - var controller = CreateController(attackerDb); + var controller = new JobApplicationLifecycleController(attackerDb); var result = await controller.GetHistory(job.Id, CancellationToken.None); diff --git a/JobTrackerApi/Controllers/JobApplicationLifecycleController.cs b/JobTrackerApi/Controllers/JobApplicationLifecycleController.cs new file mode 100644 index 0000000..c3dee93 --- /dev/null +++ b/JobTrackerApi/Controllers/JobApplicationLifecycleController.cs @@ -0,0 +1,122 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Controllers; + +[ApiController] +[Route("api/jobapplications")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class JobApplicationLifecycleController : ControllerBase +{ + private readonly JobTrackerContext _db; + + public JobApplicationLifecycleController(JobTrackerContext db) + { + _db = db; + } + + /// Canonical ordered pipeline stages so the UI renders one source of truth. + [HttpGet("pipeline")] + public ActionResult> GetPipeline() + => Ok(JobPipeline.Stages.Select(stage => new PipelineStageDto( + stage.Key, + stage.Order, + stage.Category.ToString(), + stage.Group.ToString()))); + + [HttpDelete("{id:int}")] + public async Task SoftDelete([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + if (job is null) return NotFound(); + + if (!job.IsDeleted) + { + job.IsDeleted = true; + job.DeletedAt = DateTime.Now; + _db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = "Deleted", + At = DateTime.Now + }); + await _db.SaveChangesAsync(cancellationToken); + } + + return NoContent(); + } + + [HttpPost("{id:int}/restore")] + public async Task Restore([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + if (job is null) return NotFound(); + + if (job.IsDeleted) + { + job.IsDeleted = false; + job.DeletedAt = null; + _db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = "Restored", + At = DateTime.Now + }); + await _db.SaveChangesAsync(cancellationToken); + } + + return NoContent(); + } + + [HttpPatch("{id:int}/followup")] + public async Task SetFollowUp( + [FromRoute] int id, + [FromBody] FollowUpRequest request, + CancellationToken cancellationToken) + { + var job = await _db.JobApplications.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var old = job.FollowUpAt?.ToString("o"); + job.FollowUpAt = request.FollowUpAt; + _db.JobEvents.Add(new JobEvent + { + JobApplicationId = job.Id, + Type = "FollowUpSet", + OldValue = old, + NewValue = request.FollowUpAt?.ToString("o"), + At = DateTime.Now + }); + + await _db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpGet("{id:int}/history")] + public async Task>> GetHistory( + [FromRoute] int id, + CancellationToken cancellationToken) + { + var exists = await _db.JobApplications.AnyAsync(item => item.Id == id, cancellationToken); + if (!exists) return NotFound(); + + var items = await _db.JobEvents + .AsNoTracking() + .Where(item => item.JobApplicationId == id) + .OrderByDescending(item => item.At) + .Select(item => new JobEventDto( + item.Id, + item.Type, + item.OldValue, + item.NewValue, + item.Note, + item.At)) + .ToListAsync(cancellationToken); + + return Ok(items); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 4631f1a..409bef0 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -955,11 +955,6 @@ Canonical profile: return string.Join(' ', words); } - /// Canonical ordered pipeline stages so the UI renders one source of truth. - [HttpGet("pipeline")] - public ActionResult> GetPipeline() - => Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString(), s.Group.ToString()))); - [HttpPatch("{id:int}/status")] public async Task UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken) { @@ -1094,87 +1089,6 @@ Canonical profile: return Ok(BuildJobApplicationDto(job, followUp)); } - [HttpDelete("{id:int}")] - public async Task SoftDelete([FromRoute] int id, CancellationToken cancellationToken) - { - var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); - if (job is null) return NotFound(); - - if (!job.IsDeleted) - { - job.IsDeleted = true; - job.DeletedAt = DateTime.Now; - _db.JobEvents.Add(new JobEvent - { - JobApplicationId = job.Id, - Type = "Deleted", - At = DateTime.Now - }); - await _db.SaveChangesAsync(cancellationToken); - } - - return NoContent(); - } - - [HttpPost("{id:int}/restore")] - public async Task Restore([FromRoute] int id, CancellationToken cancellationToken) - { - var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); - if (job is null) return NotFound(); - - if (job.IsDeleted) - { - job.IsDeleted = false; - job.DeletedAt = null; - _db.JobEvents.Add(new JobEvent - { - JobApplicationId = job.Id, - Type = "Restored", - At = DateTime.Now - }); - await _db.SaveChangesAsync(cancellationToken); - } - - return NoContent(); - } - - [HttpPatch("{id:int}/followup")] - public async Task SetFollowUp([FromRoute] int id, [FromBody] FollowUpRequest request, CancellationToken cancellationToken) - { - var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); - if (job is null) return NotFound(); - - var old = job.FollowUpAt?.ToString("o"); - job.FollowUpAt = request.FollowUpAt; - _db.JobEvents.Add(new JobEvent - { - JobApplicationId = job.Id, - Type = "FollowUpSet", - OldValue = old, - NewValue = request.FollowUpAt?.ToString("o"), - At = DateTime.Now - }); - - await _db.SaveChangesAsync(cancellationToken); - return NoContent(); - } - - [HttpGet("{id:int}/history")] - public async Task>> GetHistory([FromRoute] int id, CancellationToken cancellationToken) - { - var exists = await _db.JobApplications.AnyAsync(j => j.Id == id, cancellationToken); - if (!exists) return NotFound(); - - var items = await _db.JobEvents - .AsNoTracking() - .Where(e => e.JobApplicationId == id) - .OrderByDescending(e => e.At) - .Select(e => new JobEventDto(e.Id, e.Type, e.OldValue, e.NewValue, e.Note, e.At)) - .ToListAsync(cancellationToken); - - return Ok(items); - } - private static string BuildPackageModeInstruction(string? mode) { return (mode ?? string.Empty).Trim().ToLowerInvariant() switch diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index 5568539..a07bd6a 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -48,6 +48,7 @@ Updated: 2026-08-30 - Added standard-library JT-017 supply-chain gates: seven-class no-value tracked-secret scanning, deterministic CycloneDX generation across npm/NuGet/Python locks, synthetic canary tests, CI integration and an explicit advisory/credential-response policy. The current tree is clean and the SBOM contains 964 unique locked components. - Rebuilt the active developer/operator documentation around the actual Next.js 16/.NET 9 application, replaced CRA and `npm start` guidance, separated normal and Playwright ports, corrected React Router 7 and the SQLite/MariaDB provider matrix, removed the obsolete npm peer override, and verified the documented clean install, lint, test, build and locked-restore commands. - Began the JT-019 schema-ownership retirement with an executable 49-table ownership partition and transferred the leaf `SystemEmailSettings` table from MariaDB-only startup DDL to an additive provider-aware migration. Fresh SQLite now receives the table; legacy rows are preserved and startup no longer creates it. +- 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. - 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. @@ -92,6 +93,7 @@ Updated: 2026-08-30 - Gitea run 696 evidence: all backend/frontend unit stages passed; failure isolated to Playwright runtime bootstrap/execution after truncated archives and exit 139. The revised shell branch preserves ordinary non-139 failures and uses the installed CLI without `npx` fallback installation. - Post-cleanup frontend: ESLint passed with zero warnings, all 60 suites and 260/260 tests passed, optimized Next production build and integrated TypeScript passed, and Playwright passed 10/10 including public and long searchable multi-page CV PDFs. - Analytics-controller extraction: 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. - Focused frontend: 2 suites, 6 tests passed. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed.