fix(rd21): guard career and job saves with durable revisions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user