fix(jobs): derive attachment checklist flags from actual Attachments
Backlog item 4 (Wave 3, first sub-item). HasResume/HasCoverLetter/HasPortfolio/ HasOtherAttachment were manually-editable checkboxes in EditJobDialog, completely independent of whether a file was actually attached -- classic drift: mark 'resume ready' by hand, later delete the resume attachment, flag stays stuck true forever. User confirmed (asked directly, since removing the manual-override capability is a product decision, not purely technical): make them fully computed from Attachments, no manual override. - AttachmentsController.RecomputeAttachmentFlagsAsync: the single place these four fields get written now, called after every attachment mutation (upload, delete, Purpose change) that could affect them. Deliberately kept as persisted columns (not [NotMapped] computed properties reading the Attachments navigation collection) -- ~15 query sites build JobApplication DTOs without .Include(Attachments), so a live-computed property would silently return false everywhere instead of throwing, the worst kind of bug. Recomputing at the one write funnel avoids touching any read path. - Removed HasResume/etc from CreateJobApplicationRequest/ UpdateJobApplicationRequest -- no longer client-settable. - EditJobDialog: removed the manual checkboxes, kept the (now genuinely accurate) read-only status chips. - AddJobModal: stopped sending has*-flags at job-creation time; the follow-up attachment upload call now sets them correctly via the same recompute path. Caught a real bug while testing this: the Purpose-change path recomputed before saving the Purpose change, so a fresh query missed the pending edit and the flags never updated. Fixed by committing the mutation before recomputing. 3 new backend tests (purpose-change sets flag, delete clears flag, non-primary purpose counts as "other"). 172/172 backend, 25/25 frontend suites (57 tests) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -59,6 +59,26 @@ namespace JobTrackerApi.Controllers
|
||||
return "other";
|
||||
}
|
||||
|
||||
// JobApplication.HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived
|
||||
// from actual Attachment rows, not manually settable -- this is the single place they're
|
||||
// written, called after every attachment mutation (upload/delete/purpose change) so they
|
||||
// can never drift from what's actually attached.
|
||||
private async Task RecomputeAttachmentFlagsAsync(int jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken);
|
||||
if (job is null) return;
|
||||
|
||||
var purposes = await _db.Attachments
|
||||
.Where(a => a.JobApplicationId == jobId)
|
||||
.Select(a => a.Purpose)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
job.HasResume = purposes.Any(p => p == "resume");
|
||||
job.HasCoverLetter = purposes.Any(p => p == "cover-letter");
|
||||
job.HasPortfolio = purposes.Any(p => p == "portfolio");
|
||||
job.HasOtherAttachment = purposes.Any(p => p is not ("resume" or "cover-letter" or "portfolio"));
|
||||
}
|
||||
|
||||
[HttpGet("{jobId:int}")]
|
||||
public async Task<ActionResult<List<AttachmentDto>>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -102,15 +122,23 @@ namespace JobTrackerApi.Controllers
|
||||
att.UseForAi = request.UseForAi.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Purpose))
|
||||
var purposeChanged = !string.IsNullOrWhiteSpace(request.Purpose);
|
||||
if (purposeChanged)
|
||||
{
|
||||
att.Purpose = request.Purpose.Trim().ToLowerInvariant();
|
||||
att.Purpose = request.Purpose!.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
var rawName = (request.FileName ?? string.Empty).Trim();
|
||||
if (rawName.Length == 0)
|
||||
{
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (purposeChanged)
|
||||
{
|
||||
// Recompute needs the Purpose change committed first -- a fresh query
|
||||
// wouldn't see the pending change yet.
|
||||
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -130,6 +158,11 @@ namespace JobTrackerApi.Controllers
|
||||
att.FileName = name;
|
||||
att.FilePath = newPath;
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
if (purposeChanged)
|
||||
{
|
||||
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
@@ -141,8 +174,11 @@ namespace JobTrackerApi.Controllers
|
||||
if (att is null) return NotFound();
|
||||
|
||||
var path = att.FilePath;
|
||||
var jobId = att.JobApplicationId;
|
||||
_db.Attachments.Remove(att);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -200,6 +236,8 @@ namespace JobTrackerApi.Controllers
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user