fix(rd21): guard career and job saves with durable revisions

This commit is contained in:
cesnimda
2026-10-23 08:07:23 +02:00
parent 8f2cad67f7
commit 12f342c09c
32 changed files with 1185 additions and 277 deletions
@@ -459,7 +459,8 @@ Canonical profile:
TailoredCvText: job.TailoredCvText,
WorkflowSignal: workflowSignal,
ShortSummary: job.ShortSummary,
FullSummary: fullSummary);
FullSummary: fullSummary,
Version: job.Version);
}
[HttpGet]
@@ -840,6 +841,9 @@ Canonical profile:
var job = await _db.JobApplications.Include(j => j.Job).FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
return Conflict(new { code = "revision_conflict", message = "This job changed or its revision is missing. Your draft was not saved. Reload and review before saving again." });
var oldStatus = job.Status;
var oldResponseReceived = job.ResponseReceived;
var oldResponseDate = job.ResponseDate;
@@ -850,6 +854,8 @@ Canonical profile:
var title = (request.JobTitle ?? "").Trim();
if (title.Length == 0) return BadRequest("Job title is required.");
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
if (!await _db.Companies.AnyAsync(x => x.Id == request.CompanyId, cancellationToken))
return BadRequest("Company was not found.");
job.JobTitle = title;
job.CompanyId = request.CompanyId;
@@ -926,7 +932,19 @@ Canonical profile:
JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now);
if (job.Job is not null) JobOpportunitySync.Apply(job, job.Job);
await _db.SaveChangesAsync(cancellationToken);
_db.Entry(job).Property(x => x.Version).IsModified = true;
try { await _db.SaveChangesAsync(cancellationToken); }
catch (DbUpdateConcurrencyException)
{
_db.ChangeTracker.Clear();
return Conflict(new { code = "revision_conflict", message = "This job changed while saving. Your draft was not saved. Reload and review before saving again." });
}
catch (DbUpdateException)
{
_db.ChangeTracker.Clear();
throw;
}
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
return NoContent();
}
@@ -1397,7 +1415,7 @@ Candidate CV/profile:
.AsNoTracking()
.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken);
return Ok(draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft));
return Ok((draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft)) with { Revision = job.Version });
}
[HttpPost("{id:int}/tailored-cv-preview")]
@@ -1486,7 +1504,7 @@ Candidate CV/profile:
}
var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken);
return Ok(ToTailoredCvDraftDto(draft));
return Ok(ToTailoredCvDraftDto(draft) with { Revision = job.Version });
}
[HttpPut("{id:int}/tailored-cv-draft")]
@@ -1494,6 +1512,8 @@ Candidate CV/profile:
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
return Conflict(new { code = "revision_conflict", message = "The application changed. Your CV draft was not saved. Reload and review before saving again." });
var user = await GetCurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
@@ -1531,7 +1551,9 @@ Candidate CV/profile:
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
job.TailoredCvUpdatedAt = DateTime.UtcNow;
_db.Entry(job).Property(x => x.Version).IsModified = true;
await _db.SaveChangesAsync(cancellationToken);
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
return NoContent();
}
@@ -1540,10 +1562,14 @@ Candidate CV/profile:
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
return Conflict(new { code = "revision_conflict", message = "The application changed. Your draft was not saved. Reload and review before saving again." });
job.TailoredCvText = string.IsNullOrWhiteSpace(request.TailoredCvText) ? null : request.TailoredCvText.Trim();
job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow;
_db.Entry(job).Property(x => x.Version).IsModified = true;
await _db.SaveChangesAsync(cancellationToken);
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
return NoContent();
}
@@ -1552,6 +1578,8 @@ Candidate CV/profile:
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (request.ExpectedVersion is null or < 1 || request.ExpectedVersion != job.Version)
return Conflict(new { code = "revision_conflict", message = "The application changed. Your drafts were not saved. Reload and review before saving again." });
if (request.CoverLetterText is not null)
{
@@ -1575,7 +1603,9 @@ Candidate CV/profile:
: request.RecruiterMessageDraft.Trim();
}
_db.Entry(job).Property(x => x.Version).IsModified = true;
await _db.SaveChangesAsync(cancellationToken);
Response.Headers["X-Job-Version"] = job.Version.ToString(System.Globalization.CultureInfo.InvariantCulture);
return NoContent();
}