fix(jobs): derive attachment checklist flags from actual Attachments
CI and Deploy / test (pull_request) Successful in 2m4s
CI and Deploy / deploy (pull_request) Has been skipped

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:
cesnimda
2026-07-11 21:10:39 +02:00
parent 37ea1f98bb
commit b4fd5e2f96
7 changed files with 165 additions and 44 deletions
@@ -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();
}
@@ -1376,11 +1376,7 @@ Canonical profile:
string? CoverLetterText,
string? JobUrl,
DateTime? DateApplied,
DateTime? FeedbackRequestedAt,
bool? HasResume,
bool? HasCoverLetter,
bool? HasPortfolio,
bool? HasOtherAttachment
DateTime? FeedbackRequestedAt
);
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
@@ -1422,10 +1418,9 @@ Canonical profile:
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
FollowUpAt = request.FollowUpAt,
FeedbackRequestedAt = request.FeedbackRequestedAt,
HasResume = request.HasResume ?? false,
HasCoverLetter = request.HasCoverLetter ?? false,
HasPortfolio = request.HasPortfolio ?? false,
HasOtherAttachment = request.HasOtherAttachment ?? false,
// HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
// Attachment rows (see AttachmentsController.RecomputeAttachmentFlagsAsync), not
// settable here -- they start false and get set correctly once files are uploaded.
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes,
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description,
TranslatedDescription = string.IsNullOrWhiteSpace(request.TranslatedDescription) ? null : request.TranslatedDescription,
@@ -1486,10 +1481,6 @@ Canonical profile:
string? SalaryPeriod,
string? NextAction,
DateTime? FollowUpAt,
bool? HasResume,
bool? HasCoverLetter,
bool? HasPortfolio,
bool? HasOtherAttachment,
string? Notes,
string? Description,
string? TranslatedDescription,
@@ -1529,10 +1520,8 @@ Canonical profile:
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
job.FollowUpAt = request.FollowUpAt;
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
if (request.HasResume is not null) job.HasResume = request.HasResume.Value;
if (request.HasCoverLetter is not null) job.HasCoverLetter = request.HasCoverLetter.Value;
if (request.HasPortfolio is not null) job.HasPortfolio = request.HasPortfolio.Value;
if (request.HasOtherAttachment is not null) job.HasOtherAttachment = request.HasOtherAttachment.Value;
// HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
// Attachment rows, not settable here -- see AttachmentsController.RecomputeAttachmentFlagsAsync.
job.Notes = request.Notes;
job.Description = request.Description;
job.TranslatedDescription = request.TranslatedDescription;