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);
|
||||
|
||||
Reference in New Issue
Block a user