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
@@ -19,7 +19,8 @@ namespace JobTrackerApi.Controllers;
public sealed class ApplicationAssetsController : ControllerBase
{
public sealed record AttachVariantRequest(int? VariantId);
public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction);
public sealed record SaveCoverLetterRequest(string? Text, string? Source, string? AiAction, int? ExpectedVersion = null);
public sealed record RestoreCoverLetterRequest(int? ExpectedVersion);
private readonly UserManager<ApplicationUser> _users;
private readonly IApplicationAssetsService _assets;
@@ -74,16 +75,16 @@ public sealed class ApplicationAssetsController : ControllerBase
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
var result = await _assets.SaveCoverLetterAsync(
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, ct);
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("cover-letter/versions/{version:int}/restore")]
public async Task<ActionResult<CoverLetterDto>> RestoreCoverLetter(int jobId, int version, CancellationToken ct)
public async Task<ActionResult<CoverLetterDto>> RestoreCoverLetter(int jobId, int version, [FromBody] RestoreCoverLetterRequest? request, CancellationToken ct)
{
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, ct);
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct);
return result is null ? NotFound() : Ok(result);
}
+4 -4
View File
@@ -516,12 +516,14 @@ public sealed class AuthController : ControllerBase
return StatusCode(501, "Profile updates are only supported for local username/password accounts.");
}
if (request.ProfileCvText is not null || request.ProfileCvStructureJson is not null)
return BadRequest("Career data must be saved through /api/career/profile with its source revision.");
// Partial update. A field is only touched when the request actually carries it:
// - null / omitted -> leave unchanged (the caller isn't editing this field)
// - "" -> explicitly clear
// - "value" -> set (trimmed)
// This lets /profile save identity fields and /career save the master-profile fields
// through the same endpoint without one wiping the other. Email and UserName are the
// Career data uses its own atomic, revision-guarded endpoint. Email and UserName are the
// login identifiers and are never cleared to empty. Email ownership changes use the
// separate, token-confirmed email-change flow below.
if (request.UserName is not null)
@@ -532,8 +534,6 @@ public sealed class AuthController : ControllerBase
if (request.FirstName is not null) user.FirstName = TrimOrNull(request.FirstName);
if (request.LastName is not null) user.LastName = TrimOrNull(request.LastName);
if (request.DisplayName is not null) user.DisplayName = TrimOrNull(request.DisplayName);
if (request.ProfileCvText is not null) user.ProfileCvText = TrimOrNull(request.ProfileCvText);
if (request.ProfileCvStructureJson is not null) user.ProfileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson);
var res = await _users.UpdateAsync(user);
if (!res.Succeeded)
@@ -4,6 +4,7 @@ using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
@@ -35,8 +36,8 @@ public sealed class CareerProfileController : ControllerBase
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
return Ok(new CareerProfileDto(profile, CareerCompleteness.Evaluate(profile), user.ProfileCvText));
var snapshot = await _career.LoadSnapshotAsync(user.Id, cancellationToken);
return Ok(new CareerProfileDto(snapshot.Profile, CareerCompleteness.Evaluate(snapshot.Profile), snapshot.CvText, snapshot.Revision));
}
/// <summary>
@@ -50,21 +51,27 @@ public sealed class CareerProfileController : ControllerBase
{
var user = await _users.GetUserAsync(User);
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts.");
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
var profile = StructuredCvProfileJson.NormalizeForPersistence(request?.Profile);
var profile = StructuredCvProfileJson.NormalizeForPersistence(request.Profile);
var error = CareerProfileValidator.Validate(profile);
if (error is not null) return BadRequest(error);
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", cancellationToken);
// Keep the derived projection in sync for legacy readers. CvText (the raw imported text) is
// part of the career profile and is set here too; identity fields are never touched.
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved);
if (request?.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText;
var res = await _users.UpdateAsync(user);
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return Ok(new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText));
try
{
var dto = await _career.ExecuteAtomicAsync(async () =>
{
var saved = await _career.SaveVersionAsync(user.Id, profile, "manual", request.ExpectedVersion.Value, cancellationToken);
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(saved);
if (request.CvText is not null) user.ProfileCvText = string.IsNullOrWhiteSpace(request.CvText) ? null : request.CvText;
var res = await _users.UpdateAsync(user);
if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description)));
return new CareerProfileDto(saved, CareerCompleteness.Evaluate(saved), user.ProfileCvText, request.ExpectedVersion.Value + 1);
}, cancellationToken);
return Ok(dto);
}
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload and review your draft before saving." }); }
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
}
/// <summary>Just the completeness scorecard, for the /career overview.</summary>
@@ -92,25 +99,34 @@ public sealed class CareerProfileController : ControllerBase
/// </summary>
[HttpPost("versions/{version:int}/restore")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<CareerProfileDto>> Restore([FromRoute] int version, CancellationToken cancellationToken)
public async Task<ActionResult<CareerProfileDto>> Restore([FromRoute] int version, [FromBody] CareerProfileRestoreRequest? request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return StatusCode(501, "The career profile can only be edited on local accounts.");
if (request is null || request.ExpectedVersion is null or < 0) return BadRequest("expectedVersion is required.");
var restored = await _career.RestoreVersionAsync(user.Id, version, cancellationToken);
if (restored is null) return NotFound($"Version {version} was not found.");
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored);
var res = await _users.UpdateAsync(user);
if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
return Ok(new CareerProfileDto(restored, CareerCompleteness.Evaluate(restored), user.ProfileCvText));
try
{
var dto = await _career.ExecuteAtomicAsync(async () =>
{
var restored = await _career.RestoreVersionAsync(user.Id, version, request.ExpectedVersion.Value, cancellationToken);
if (restored is null) return null;
user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(restored);
var res = await _users.UpdateAsync(user);
if (!res.Succeeded) throw new CareerProjectionException(string.Join("; ", res.Errors.Select(e => e.Description)));
return new CareerProfileDto(restored, CareerCompleteness.Evaluate(restored), user.ProfileCvText, request.ExpectedVersion.Value + 1);
}, cancellationToken);
return dto is null ? NotFound($"Version {version} was not found.") : Ok(dto);
}
catch (DbUpdateConcurrencyException) { return Conflict(new { code = "revision_conflict", message = "The career profile changed. Reload before restoring a version." }); }
catch (CareerProjectionException ex) { return BadRequest(ex.Message); }
}
}
public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText);
public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText, int? ExpectedVersion = null);
public sealed record CareerProfileRestoreRequest(int? ExpectedVersion);
public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness, string? CvText);
public sealed record CareerProfileDto(StructuredCvProfile Profile, CareerCompletenessDto Completeness, string? CvText, int Revision);
public sealed record CareerCompletenessDto(int Percent, List<string> Missing, List<CareerSectionStatusDto> Sections);
@@ -79,7 +79,8 @@ namespace JobTrackerApi.Controllers
string? TailoredCvText,
WorkflowSignalDto WorkflowSignal,
string? ShortSummary,
string? FullSummary
string? FullSummary,
int Version = 1
);
public sealed record CreateJobApplicationRequest(
@@ -132,7 +133,8 @@ namespace JobTrackerApi.Controllers
string? JobUrl,
DateTime? DateApplied,
DateTime? FeedbackRequestedAt,
DateTime? StatusChangedAt
DateTime? StatusChangedAt,
int? ExpectedVersion = null
);
public sealed record UpdateStatusRequest(string Status);
@@ -189,7 +191,7 @@ namespace JobTrackerApi.Controllers
CandidateFitChannelGuidanceDto Guidance,
string? CoverLetterDraft,
string? RecruiterMessageDraft);
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
public sealed record SaveTailoredCvRequest(string? TailoredCvText, int? ExpectedVersion = null);
public sealed record TailoredCvDraftDto(
int? Id,
int? CanonicalProfileVersion,
@@ -206,7 +208,8 @@ namespace JobTrackerApi.Controllers
DateTimeOffset? LastEditedAtUtc,
string Status,
string RenderedText,
bool IsLegacyFallback);
bool IsLegacyFallback,
int Revision = 1);
public sealed record SaveTailoredCvDraftRequest(
string? TemplateId,
string? Headline,
@@ -216,13 +219,15 @@ namespace JobTrackerApi.Controllers
List<TailoredCvEducationItem>? Education,
List<TailoredCvCustomSection>? CustomSections,
TailoredCvRenderOptions? RenderOptions,
string? Status);
string? Status,
int? ExpectedVersion = null);
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
public sealed record SaveApplicationDraftsRequest(
string? CoverLetterText,
string? Notes,
string? RecruiterMessageDraft,
string? ApplicationAnswerDraft = null);
string? ApplicationAnswerDraft = null,
int? ExpectedVersion = null);
public sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders, WorkflowSignalDto WorkflowSignal);
@@ -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();
}
@@ -248,53 +248,60 @@ public sealed partial class ProfileCvController : ControllerBase
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
}
private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken)
private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, int expectedVersion, CancellationToken cancellationToken)
{
var run = new CvExtractionRun
await _careerProfileService.ExecuteAtomicAsync(async () =>
{
OwnerUserId = user.Id,
ArtifactId = artifactId,
Trigger = trigger,
ParserVersion = ParserVersion,
NormalizerVersion = NormalizerVersion,
LlmPromptVersion = LlmPromptVersion,
Status = "applied",
RawExtractedText = rawText,
NormalizedText = normalizedText,
StartedAtUtc = DateTimeOffset.UtcNow,
CompletedAtUtc = DateTimeOffset.UtcNow,
AppliedAtUtc = DateTimeOffset.UtcNow,
};
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(cancellationToken);
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.StructuredProfileJson = structuredJson;
user.ProfileCvText = normalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
if (artifactId.HasValue)
{
user.CurrentCvUploadArtifactId = artifactId.Value;
}
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
var run = new CvExtractionRun
{
OwnerUserId = user.Id,
ArtifactId = artifactId,
Trigger = trigger,
ParserVersion = ParserVersion,
NormalizerVersion = NormalizerVersion,
LlmPromptVersion = LlmPromptVersion,
Status = "applied",
RawExtractedText = rawText,
NormalizedText = normalizedText,
StartedAtUtc = DateTimeOffset.UtcNow,
CompletedAtUtc = DateTimeOffset.UtcNow,
AppliedAtUtc = DateTimeOffset.UtcNow,
};
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(cancellationToken);
throw new InvalidOperationException(run.ErrorMessage);
}
await _db.SaveChangesAsync(cancellationToken);
await PruneExtractionRunsAsync(user.Id, cancellationToken);
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, expectedVersion, cancellationToken);
var structuredJson = StructuredCvProfileJson.SerializePersisted(structuredCv);
run.StructuredProfileJson = structuredJson;
user.ProfileCvText = normalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
if (artifactId.HasValue)
{
user.CurrentCvUploadArtifactId = artifactId.Value;
}
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
throw new CareerProjectionException(string.Join("; ", update.Errors.Select(e => e.Description)));
}
return true;
}, cancellationToken);
// Retention is maintenance; a failure cannot turn a committed profile into a rejected save.
try { await PruneExtractionRunsAsync(user.Id, cancellationToken); }
catch (Exception ex)
{
_db.ChangeTracker.Clear();
_logger.LogWarning(ex, "Could not prune CV extraction history after the profile commit for user {UserId}", user.Id);
}
}
private async Task<CvExtractionRun> CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken)
@@ -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")]
+1 -1
View File
@@ -2,7 +2,7 @@ using JobTrackerApi.Models;
namespace JobTrackerApi.Controllers;
public sealed record ParseCvRequest(string? Text);
public sealed record ParseCvRequest(string? Text, int? ExpectedVersion = null);
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets);
public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId);
public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null);
+25
View File
@@ -72,10 +72,35 @@ namespace JobTrackerApi.Data
public DbSet<AccountDeletionRequest> AccountDeletionRequests => Set<AccountDeletionRequest>();
public DbSet<AccountDeletionFile> AccountDeletionFiles => Set<AccountDeletionFile>();
private void AdvanceJobRevisions()
{
ChangeTracker.DetectChanges();
foreach (var entry in ChangeTracker.Entries<JobApplication>().Where(x => x.State == EntityState.Modified))
{
// All writers share this guard, including partial updates and background workers.
entry.Entity.Version = checked(entry.Property(x => x.Version).OriginalValue + 1);
}
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
AdvanceJobRevisions();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
AdvanceJobRevisions();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<JobApplication>().Property(x => x.Version).IsConcurrencyToken().HasDefaultValue(1);
modelBuilder.Entity<CareerProfile>().Property(x => x.Version).IsConcurrencyToken();
modelBuilder.Entity<ApplicationUser>()
.Property(x => x.PendingEmail)
.HasMaxLength(320);
@@ -0,0 +1,25 @@
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations;
[DbContext(typeof(JobTrackerContext))]
[Migration("20261023090000_AddJobRevision")]
public partial class AddJobRevision : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) => migrationBuilder.AddColumn<int>(
name: "Version", table: "JobApplications",
type: ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "int" : "INTEGER",
nullable: false, defaultValue: 1);
protected override void Down(MigrationBuilder migrationBuilder)
{
if (ActiveProvider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
migrationBuilder.Sql("ALTER TABLE \"JobApplications\" DROP COLUMN \"Version\";");
else
migrationBuilder.DropColumn(name: "Version", table: "JobApplications");
}
}
@@ -803,6 +803,7 @@ namespace JobTrackerApi.Migrations
.HasColumnType("TEXT");
b.Property<int>("Version")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.HasKey("Id");
@@ -1938,6 +1939,12 @@ namespace JobTrackerApi.Migrations
b.Property<string>("TranslatedDescription")
.HasColumnType("TEXT");
b.Property<int>("Version")
.IsConcurrencyToken()
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.HasKey("Id");
b.HasIndex("CompanyId");
+1
View File
@@ -5,6 +5,7 @@ namespace JobTrackerApi.Models
public class JobApplication
{
public int Id { get; set; }
public int Version { get; set; } = 1;
public string? OwnerUserId { get; set; }
public string JobTitle { get; set; } = "";
public int CompanyId { get; set; }
+1
View File
@@ -160,6 +160,7 @@ builder.Services.AddProblemDetails(options =>
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
});
builder.Services.AddExceptionHandler<AiUsageLimitExceptionHandler>();
builder.Services.AddExceptionHandler<RevisionConflictExceptionHandler>();
builder.Services.AddExceptionHandler<PdfRenderExceptionHandler>();
builder.Services.AddOpenApi();
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
@@ -41,7 +41,8 @@ public sealed record CoverLetterDto(
string? Text,
int CurrentVersion,
IReadOnlyList<CoverLetterVersionDto> Versions,
int AiSuggestionCount);
int AiSuggestionCount,
int Revision = 1);
public interface IApplicationAssetsService
{
@@ -50,8 +51,8 @@ public interface IApplicationAssetsService
Task<TailoringPlanDto?> GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CoverLetterDto?> GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct);
Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct);
Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct);
Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct);
}
public sealed class ApplicationAssetsService : IApplicationAssetsService
@@ -237,7 +238,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
// Every save snapshots the PREVIOUS text first, so an AI rewrite can always be undone. The user's
// text is what gets stored — an AI suggestion only becomes a version once the user saves it, which
// is what "requires approval" means here.
public async Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct)
public async Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct)
{
var job = await _db.JobApplications
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
@@ -246,9 +247,15 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
var next = text?.Trim() ?? string.Empty;
var current = job.CoverLetterText?.Trim() ?? string.Empty;
if (expectedVersion is null or < 1 || expectedVersion != job.Version)
throw new DbUpdateConcurrencyException("The application changed after this draft was loaded.");
// Nothing changed: do not spend a version on a no-op save (autosave calls this often).
if (string.Equals(next, current, StringComparison.Ordinal))
{
// A no-op still consumes the source revision so an overlapping write cannot be acknowledged as this draft.
_db.Entry(job).Property(x => x.Version).IsModified = true;
await _db.SaveChangesAsync(ct);
return await BuildCoverLetterAsync(ownerUserId, job, ct);
}
@@ -274,7 +281,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
// Restore is non-destructive: the old text comes back as a NEW version, so the thing you restored
// from is still in the history.
public async Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct)
public async Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct)
{
var job = await _db.JobApplications
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
@@ -284,7 +291,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
.FirstOrDefaultAsync(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId && v.Version == version, ct);
if (snapshot is null) return null;
return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, ct);
return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, expectedVersion, ct);
}
private async Task<CoverLetterDto> BuildCoverLetterAsync(string ownerUserId, JobApplication job, CancellationToken ct)
@@ -304,7 +311,8 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
current,
versions.Select(v => new CoverLetterVersionDto(
v.Version, v.Source, v.AiAction, v.Text.Length, v.CreatedAtUtc, v.Version == current)).ToList(),
aiCount);
aiCount,
job.Version);
}
private async Task<int> NextVersionAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
@@ -47,7 +47,8 @@ public sealed record WorkspaceOverviewDto(
DateTimeOffset? LastAiAtUtc,
IReadOnlyList<WorkspaceActivityDto> RecentActivity,
WorkspaceNextStepDto? NextStep,
ChecklistProgressDto? ChecklistProgress);
ChecklistProgressDto? ChecklistProgress,
int Revision = 1);
public interface IApplicationWorkspaceService
{
@@ -147,7 +148,8 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
lastAi,
activity,
NextStep(checklist),
checklist?.Progress);
checklist?.Progress,
job.Version);
}
// "The user should never ask what to do next." Milestone 2 moved this onto the checklist: the first
+122 -61
View File
@@ -15,12 +15,17 @@ public interface ICareerProfileService
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
// it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, syncs
// the relational children (Experience/Education/Skill/Project/Certification/Language), and
// returns the same profile so the caller can go on to serialize it into the legacy column.
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);
// returns the same profile. Controller writes include the legacy projection in ExecuteAtomicAsync.
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken);
Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken);
// Includes Identity projection and import metadata in the canonical commit.
Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken);
// Reads the master profile from the relational children (the source of truth). If the relational
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), it is
// backfilled from the blob first. Returns an empty profile if the user has none.
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), reads fall
// back to that blob without writing. Returns an empty profile if the user has none.
Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken);
// The append-only version history, newest first.
@@ -29,7 +34,7 @@ public interface ICareerProfileService
// Restores a past version by re-saving its snapshot as a NEW version (non-destructive: history
// and the current state are both preserved; the restore is itself reversible). Returns the
// restored profile, or null if the version does not exist.
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken);
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken);
// Read-only structured load for a specific owner, bypassing the tenant query filter. For public
// CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never
@@ -38,6 +43,8 @@ public interface ICareerProfileService
}
public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
public sealed record CareerProfileSnapshot(StructuredCvProfile Profile, int Revision, string? CvText = null);
public sealed class CareerProjectionException(string message) : Exception(message);
public sealed class CareerProfileService : ICareerProfileService
{
@@ -48,63 +55,119 @@ public sealed class CareerProfileService : ICareerProfileService
_db = db;
}
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
public async Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken)
{
AssignStableIds(profile);
NormalizeDates(profile);
// Publish the profile row, relational children, and history revision atomically. Without
// this boundary a concurrent outline/read can observe the new profile before its children,
// enter the legacy backfill path, and insert a second copy of every child row.
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
? await _db.Database.BeginTransactionAsync(cancellationToken)
: null;
var json = StructuredCvProfileJson.SerializePersisted(profile);
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
try
{
existing = new CareerProfile
var result = await action();
await _db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return result;
}
catch
{
try
{
if (transaction is not null) await transaction.RollbackAsync(CancellationToken.None);
}
finally
{
// A later SaveChanges must never publish an operation whose response failed.
_db.ChangeTracker.Clear();
}
throw;
}
}
public async Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken)
{
// Hold a read snapshot across the canonical row and relational children.
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
? await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken)
: null;
var row = await _db.CareerProfiles.AsNoTracking().Where(x => x.OwnerUserId == ownerUserId)
.Select(x => new
{
x.Version,
CvText = _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefault(),
}).FirstOrDefaultAsync(cancellationToken);
var snapshot = row is null ? new CareerProfileSnapshot(new StructuredCvProfile(), 0,
await _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefaultAsync(cancellationToken))
: new(await LoadStructuredAsync(ownerUserId, cancellationToken), row.Version, row.CvText);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return snapshot;
}
public Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken) =>
ExecuteAtomicAsync(async () =>
{
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (expectedVersion < 0 || (existing?.Version ?? 0) != expectedVersion)
throw new DbUpdateConcurrencyException("The career profile changed after it was loaded.");
AssignStableIds(profile);
NormalizeDates(profile);
var json = StructuredCvProfileJson.SerializePersisted(profile);
if (existing is null)
{
existing = new CareerProfile
{
OwnerUserId = ownerUserId,
ProfileJson = json,
Version = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
_db.CareerProfiles.Add(existing);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
try
{
await _db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (expectedVersion == 0 && IsDuplicateOwner(ex))
{
throw new DbUpdateConcurrencyException("The career profile was created by another request.", ex);
}
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
_db.CareerProfileVersions.Add(new CareerProfileVersion
{
OwnerUserId = ownerUserId,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json,
Version = 1,
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
_db.CareerProfiles.Add(existing);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
});
return profile;
}, cancellationToken);
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
await _db.SaveChangesAsync(cancellationToken);
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
_db.CareerProfileVersions.Add(new CareerProfileVersion
{
OwnerUserId = ownerUserId,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json,
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await _db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return profile;
}
private static bool IsDuplicateOwner(DbUpdateException exception) => exception.InnerException switch
{
Microsoft.Data.Sqlite.SqliteException sqlite => sqlite.SqliteErrorCode == 19
&& sqlite.Message.Contains("UNIQUE constraint failed: CareerProfiles.OwnerUserId", StringComparison.Ordinal),
MySqlConnector.MySqlException mysql => mysql.Number == 1062
&& mysql.Message.Contains("IX_CareerProfiles_OwnerUserId", StringComparison.Ordinal),
_ => false,
};
public async Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken)
{
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
var profile = await _db.CareerProfiles.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return new StructuredCvProfile();
var hasRelational = await _db.CareerExperiences.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
@@ -114,21 +177,19 @@ public sealed class CareerProfileService : ICareerProfileService
|| await _db.CareerCertifications.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|| await _db.CareerLanguages.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken);
// Backfill a pre-Phase-3 profile from its blob, once, before reading relationally.
// A read cannot materialize unversioned children during a concurrent replacement.
if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson))
{
var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
AssignStableIds(fromBlob);
NormalizeDates(fromBlob);
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
return fromBlob;
}
var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var experiences = UniqueChildren(await _db.CareerExperiences.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var education = UniqueChildren(await _db.CareerEducations.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var skills = UniqueChildren(await _db.CareerSkills.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var projects = UniqueChildren(await _db.CareerProjects.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var certifications = UniqueChildren(await _db.CareerCertifications.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var languages = UniqueChildren(await _db.CareerLanguages.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
}
@@ -170,7 +231,7 @@ public sealed class CareerProfileService : ICareerProfileService
return versions.Select(x => new CareerProfileVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == profile.Version)).ToList();
}
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken)
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken)
{
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return null;
@@ -181,7 +242,7 @@ public sealed class CareerProfileService : ICareerProfileService
// Re-save the old snapshot as a new version. Non-destructive: the current state stays in
// history, so a restore can itself be undone by restoring the version before it.
var restored = StructuredCvProfileJson.DeserializePersisted(target.ProfileJson);
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken);
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", expectedVersion, cancellationToken);
}
// Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed class RevisionConflictExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
{
if (exception is not DbUpdateConcurrencyException) return false;
context.Response.StatusCode = StatusCodes.Status409Conflict;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status409Conflict,
Title = "The record changed",
Detail = "Your draft was not saved. Reload and review the current record before saving again.",
Extensions = { ["code"] = "revision_conflict" },
}, cancellationToken);
return true;
}
}