refactor(jobs): extract lifecycle endpoints
This commit is contained in:
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# Blockers
|
# Blockers
|
||||||
|
|
||||||
Updated: 2026-08-15
|
Updated: 2026-08-31
|
||||||
|
|
||||||
## Stripe billing
|
## 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.
|
- **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.
|
- **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 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
|
## Account deletion retention and restore policy
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public sealed class JobApplicationsAuthorizationTests
|
|||||||
await ownerDb.SaveChangesAsync();
|
await ownerDb.SaveChangesAsync();
|
||||||
|
|
||||||
await using var attackerDb = CreateDb(dbName, "other-user");
|
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);
|
var result = await controller.GetHistory(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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(stage => new PipelineStageDto(
|
||||||
|
stage.Key,
|
||||||
|
stage.Order,
|
||||||
|
stage.Category.ToString(),
|
||||||
|
stage.Group.ToString())));
|
||||||
|
|
||||||
|
[HttpDelete("{id:int}")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<ActionResult<List<JobEventDto>>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -955,11 +955,6 @@ Canonical profile:
|
|||||||
return string.Join(' ', words);
|
return string.Join(' ', words);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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(), s.Group.ToString())));
|
|
||||||
|
|
||||||
[HttpPatch("{id:int}/status")]
|
[HttpPatch("{id:int}/status")]
|
||||||
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
|
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -1094,87 +1089,6 @@ Canonical profile:
|
|||||||
return Ok(BuildJobApplicationDto(job, followUp));
|
return Ok(BuildJobApplicationDto(job, followUp));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:int}")]
|
|
||||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<ActionResult<List<JobEventDto>>> 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)
|
private static string BuildPackageModeInstruction(string? mode)
|
||||||
{
|
{
|
||||||
return (mode ?? string.Empty).Trim().ToLowerInvariant() switch
|
return (mode ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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