fix(rd21): guard career and job saves with durable revisions
This commit is contained in:
@@ -162,7 +162,7 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
// in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating
|
||||
// fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work
|
||||
// (Phase 4), not this foundation integration.
|
||||
public sealed record AcceptCvRunRequest(List<string>? AcceptedLowConfidenceIds);
|
||||
public sealed record AcceptCvRunRequest(List<string>? AcceptedLowConfidenceIds, int? ExpectedVersion = null);
|
||||
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
||||
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||
|
||||
@@ -258,9 +258,10 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
if (run is null) return NotFound();
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
|
||||
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
|
||||
var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted);
|
||||
var current = snapshot.Profile;
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
return Ok(new { runId = run.Id, run.Status, diff = _cvProfileDiffService.Diff(current, extracted) });
|
||||
return Ok(new { runId = run.Id, run.Status, revision = snapshot.Revision, diff = _cvProfileDiffService.Diff(current, extracted) });
|
||||
}
|
||||
|
||||
[HttpPost("runs/{id:int}/accept")]
|
||||
@@ -268,34 +269,45 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
|
||||
|
||||
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
if (run is null) return NotFound();
|
||||
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
try
|
||||
{
|
||||
return await _careerProfileService.ExecuteAtomicAsync<IActionResult>(async () =>
|
||||
{
|
||||
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted);
|
||||
if (run is null) return NotFound();
|
||||
if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review.");
|
||||
if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile.");
|
||||
|
||||
var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted);
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
var diff = _cvProfileDiffService.Diff(current, extracted);
|
||||
var acceptedLowConfidenceIds = (request?.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
|
||||
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
|
||||
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
merged.Metadata.AppliedExtractionRunId = run.Id;
|
||||
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted);
|
||||
var snapshot = await _careerProfileService.LoadSnapshotAsync(user.Id, HttpContext.RequestAborted);
|
||||
if (snapshot.Revision != request.ExpectedVersion.Value)
|
||||
throw new DbUpdateConcurrencyException("The career profile changed after import review.");
|
||||
var current = snapshot.Profile;
|
||||
var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson);
|
||||
var diff = _cvProfileDiffService.Diff(current, extracted);
|
||||
var acceptedLowConfidenceIds = (request.AcceptedLowConfidenceIds ?? new List<string>()).ToHashSet(StringComparer.Ordinal);
|
||||
var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds);
|
||||
merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||
merged.Metadata.AppliedExtractionRunId = run.Id;
|
||||
merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", request.ExpectedVersion.Value, HttpContext.RequestAborted);
|
||||
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged);
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
|
||||
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
|
||||
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged);
|
||||
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText;
|
||||
user.CurrentCvExtractionRunId = run.Id;
|
||||
user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion;
|
||||
if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value;
|
||||
|
||||
run.Status = "applied";
|
||||
run.AppliedAtUtc = DateTimeOffset.UtcNow;
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description)));
|
||||
await _db.SaveChangesAsync(HttpContext.RequestAborted);
|
||||
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged });
|
||||
run.Status = "applied";
|
||||
run.AppliedAtUtc = DateTimeOffset.UtcNow;
|
||||
var update = await _users.UpdateAsync(user);
|
||||
if (!update.Succeeded) throw new CareerProjectionException(string.Join("; ", update.Errors.Select(e => e.Description)));
|
||||
return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged, revision = request.ExpectedVersion.Value + 1 });
|
||||
}, HttpContext.RequestAborted);
|
||||
}
|
||||
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Review the import again before accepting." }); }
|
||||
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
|
||||
}
|
||||
|
||||
[HttpPost("runs/{id:int}/discard")]
|
||||
@@ -522,19 +534,20 @@ public sealed partial class ProfileCvController : ControllerBase
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
|
||||
|
||||
var source = string.IsNullOrWhiteSpace(request?.Text) ? user.ProfileCvText : request!.Text;
|
||||
var source = string.IsNullOrWhiteSpace(request.Text) ? user.ProfileCvText : request.Text;
|
||||
if (string.IsNullOrWhiteSpace(source)) return BadRequest("Add or import CV text before parsing sections.");
|
||||
|
||||
var normalizedSource = await MaybeReconstructStructuredCvAsync(source, HttpContext.RequestAborted);
|
||||
var structuredCv = await BuildStructuredCvAsync(normalizedSource, HttpContext.RequestAborted);
|
||||
if (string.IsNullOrWhiteSpace(request?.Text))
|
||||
try
|
||||
{
|
||||
user.ProfileCvText = normalizedSource;
|
||||
await ApplyTextExtractionRunAsync(user, "parse", source, normalizedSource, structuredCv, user.CurrentCvUploadArtifactId, request.ExpectedVersion.Value, HttpContext.RequestAborted);
|
||||
return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion, revision = request.ExpectedVersion.Value + 1 });
|
||||
}
|
||||
await ApplyTextExtractionRunAsync(user, "parse", source, normalizedSource, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted);
|
||||
|
||||
return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion });
|
||||
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload before parsing." }); }
|
||||
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
|
||||
}
|
||||
|
||||
[HttpPost("improve")]
|
||||
|
||||
Reference in New Issue
Block a user