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

This commit is contained in:
cesnimda
2026-10-23 08:07:23 +02:00
parent 8f2cad67f7
commit 12f342c09c
32 changed files with 1185 additions and 277 deletions
@@ -41,7 +41,8 @@ public sealed record CoverLetterDto(
string? Text,
int CurrentVersion,
IReadOnlyList<CoverLetterVersionDto> Versions,
int AiSuggestionCount);
int AiSuggestionCount,
int Revision = 1);
public interface IApplicationAssetsService
{
@@ -50,8 +51,8 @@ public interface IApplicationAssetsService
Task<TailoringPlanDto?> GetTailoringPlanAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CoverLetterDto?> GetCoverLetterAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct);
Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct);
Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct);
Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct);
}
public sealed class ApplicationAssetsService : IApplicationAssetsService
@@ -237,7 +238,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
// Every save snapshots the PREVIOUS text first, so an AI rewrite can always be undone. The user's
// text is what gets stored — an AI suggestion only becomes a version once the user saves it, which
// is what "requires approval" means here.
public async Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, CancellationToken ct)
public async Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct)
{
var job = await _db.JobApplications
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
@@ -246,9 +247,15 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
var next = text?.Trim() ?? string.Empty;
var current = job.CoverLetterText?.Trim() ?? string.Empty;
if (expectedVersion is null or < 1 || expectedVersion != job.Version)
throw new DbUpdateConcurrencyException("The application changed after this draft was loaded.");
// Nothing changed: do not spend a version on a no-op save (autosave calls this often).
if (string.Equals(next, current, StringComparison.Ordinal))
{
// A no-op still consumes the source revision so an overlapping write cannot be acknowledged as this draft.
_db.Entry(job).Property(x => x.Version).IsModified = true;
await _db.SaveChangesAsync(ct);
return await BuildCoverLetterAsync(ownerUserId, job, ct);
}
@@ -274,7 +281,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
// Restore is non-destructive: the old text comes back as a NEW version, so the thing you restored
// from is still in the history.
public async Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, CancellationToken ct)
public async Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct)
{
var job = await _db.JobApplications
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
@@ -284,7 +291,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
.FirstOrDefaultAsync(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId && v.Version == version, ct);
if (snapshot is null) return null;
return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, ct);
return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, expectedVersion, ct);
}
private async Task<CoverLetterDto> BuildCoverLetterAsync(string ownerUserId, JobApplication job, CancellationToken ct)
@@ -304,7 +311,8 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
current,
versions.Select(v => new CoverLetterVersionDto(
v.Version, v.Source, v.AiAction, v.Text.Length, v.CreatedAtUtc, v.Version == current)).ToList(),
aiCount);
aiCount,
job.Version);
}
private async Task<int> NextVersionAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
@@ -47,7 +47,8 @@ public sealed record WorkspaceOverviewDto(
DateTimeOffset? LastAiAtUtc,
IReadOnlyList<WorkspaceActivityDto> RecentActivity,
WorkspaceNextStepDto? NextStep,
ChecklistProgressDto? ChecklistProgress);
ChecklistProgressDto? ChecklistProgress,
int Revision = 1);
public interface IApplicationWorkspaceService
{
@@ -147,7 +148,8 @@ public sealed class ApplicationWorkspaceService : IApplicationWorkspaceService
lastAi,
activity,
NextStep(checklist),
checklist?.Progress);
checklist?.Progress,
job.Version);
}
// "The user should never ask what to do next." Milestone 2 moved this onto the checklist: the first
+122 -61
View File
@@ -15,12 +15,17 @@ public interface ICareerProfileService
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
// it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, syncs
// the relational children (Experience/Education/Skill/Project/Certification/Language), and
// returns the same profile so the caller can go on to serialize it into the legacy column.
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);
// returns the same profile. Controller writes include the legacy projection in ExecuteAtomicAsync.
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken);
Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken);
// Includes Identity projection and import metadata in the canonical commit.
Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken);
// Reads the master profile from the relational children (the source of truth). If the relational
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), it is
// backfilled from the blob first. Returns an empty profile if the user has none.
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), reads fall
// back to that blob without writing. Returns an empty profile if the user has none.
Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken);
// The append-only version history, newest first.
@@ -29,7 +34,7 @@ public interface ICareerProfileService
// Restores a past version by re-saving its snapshot as a NEW version (non-destructive: history
// and the current state are both preserved; the restore is itself reversible). Returns the
// restored profile, or null if the version does not exist.
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken);
Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken);
// Read-only structured load for a specific owner, bypassing the tenant query filter. For public
// CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never
@@ -38,6 +43,8 @@ public interface ICareerProfileService
}
public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
public sealed record CareerProfileSnapshot(StructuredCvProfile Profile, int Revision, string? CvText = null);
public sealed class CareerProjectionException(string message) : Exception(message);
public sealed class CareerProfileService : ICareerProfileService
{
@@ -48,63 +55,119 @@ public sealed class CareerProfileService : ICareerProfileService
_db = db;
}
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
public async Task<T> ExecuteAtomicAsync<T>(Func<Task<T>> action, CancellationToken cancellationToken)
{
AssignStableIds(profile);
NormalizeDates(profile);
// Publish the profile row, relational children, and history revision atomically. Without
// this boundary a concurrent outline/read can observe the new profile before its children,
// enter the legacy backfill path, and insert a second copy of every child row.
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
? await _db.Database.BeginTransactionAsync(cancellationToken)
: null;
var json = StructuredCvProfileJson.SerializePersisted(profile);
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
try
{
existing = new CareerProfile
var result = await action();
await _db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return result;
}
catch
{
try
{
if (transaction is not null) await transaction.RollbackAsync(CancellationToken.None);
}
finally
{
// A later SaveChanges must never publish an operation whose response failed.
_db.ChangeTracker.Clear();
}
throw;
}
}
public async Task<CareerProfileSnapshot> LoadSnapshotAsync(string ownerUserId, CancellationToken cancellationToken)
{
// Hold a read snapshot across the canonical row and relational children.
await using var transaction = _db.Database.IsRelational() && _db.Database.CurrentTransaction is null
? await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, cancellationToken)
: null;
var row = await _db.CareerProfiles.AsNoTracking().Where(x => x.OwnerUserId == ownerUserId)
.Select(x => new
{
x.Version,
CvText = _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefault(),
}).FirstOrDefaultAsync(cancellationToken);
var snapshot = row is null ? new CareerProfileSnapshot(new StructuredCvProfile(), 0,
await _db.Users.Where(user => user.Id == ownerUserId).Select(user => user.ProfileCvText).FirstOrDefaultAsync(cancellationToken))
: new(await LoadStructuredAsync(ownerUserId, cancellationToken), row.Version, row.CvText);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return snapshot;
}
public Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, int expectedVersion, CancellationToken cancellationToken) =>
ExecuteAtomicAsync(async () =>
{
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (expectedVersion < 0 || (existing?.Version ?? 0) != expectedVersion)
throw new DbUpdateConcurrencyException("The career profile changed after it was loaded.");
AssignStableIds(profile);
NormalizeDates(profile);
var json = StructuredCvProfileJson.SerializePersisted(profile);
if (existing is null)
{
existing = new CareerProfile
{
OwnerUserId = ownerUserId,
ProfileJson = json,
Version = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
_db.CareerProfiles.Add(existing);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
try
{
await _db.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException ex) when (expectedVersion == 0 && IsDuplicateOwner(ex))
{
throw new DbUpdateConcurrencyException("The career profile was created by another request.", ex);
}
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
_db.CareerProfileVersions.Add(new CareerProfileVersion
{
OwnerUserId = ownerUserId,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json,
Version = 1,
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
_db.CareerProfiles.Add(existing);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
});
return profile;
}, cancellationToken);
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
await _db.SaveChangesAsync(cancellationToken);
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
_db.CareerProfileVersions.Add(new CareerProfileVersion
{
OwnerUserId = ownerUserId,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json,
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await _db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return profile;
}
private static bool IsDuplicateOwner(DbUpdateException exception) => exception.InnerException switch
{
Microsoft.Data.Sqlite.SqliteException sqlite => sqlite.SqliteErrorCode == 19
&& sqlite.Message.Contains("UNIQUE constraint failed: CareerProfiles.OwnerUserId", StringComparison.Ordinal),
MySqlConnector.MySqlException mysql => mysql.Number == 1062
&& mysql.Message.Contains("IX_CareerProfiles_OwnerUserId", StringComparison.Ordinal),
_ => false,
};
public async Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken)
{
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
var profile = await _db.CareerProfiles.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return new StructuredCvProfile();
var hasRelational = await _db.CareerExperiences.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
@@ -114,21 +177,19 @@ public sealed class CareerProfileService : ICareerProfileService
|| await _db.CareerCertifications.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|| await _db.CareerLanguages.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken);
// Backfill a pre-Phase-3 profile from its blob, once, before reading relationally.
// A read cannot materialize unversioned children during a concurrent replacement.
if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson))
{
var fromBlob = StructuredCvProfileJson.DeserializePersisted(profile.ProfileJson);
AssignStableIds(fromBlob);
NormalizeDates(fromBlob);
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
return fromBlob;
}
var experiences = UniqueChildren(await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var education = UniqueChildren(await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var skills = UniqueChildren(await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var projects = UniqueChildren(await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var certifications = UniqueChildren(await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var languages = UniqueChildren(await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var experiences = UniqueChildren(await _db.CareerExperiences.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var education = UniqueChildren(await _db.CareerEducations.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var skills = UniqueChildren(await _db.CareerSkills.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var projects = UniqueChildren(await _db.CareerProjects.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var certifications = UniqueChildren(await _db.CareerCertifications.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
var languages = UniqueChildren(await _db.CareerLanguages.AsNoTracking().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync(cancellationToken));
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
}
@@ -170,7 +231,7 @@ public sealed class CareerProfileService : ICareerProfileService
return versions.Select(x => new CareerProfileVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == profile.Version)).ToList();
}
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken)
public async Task<StructuredCvProfile?> RestoreVersionAsync(string ownerUserId, int version, int expectedVersion, CancellationToken cancellationToken)
{
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return null;
@@ -181,7 +242,7 @@ public sealed class CareerProfileService : ICareerProfileService
// Re-save the old snapshot as a new version. Non-destructive: the current state stays in
// history, so a restore can itself be undone by restoring the version before it.
var restored = StructuredCvProfileJson.DeserializePersisted(target.ProfileJson);
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken);
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", expectedVersion, cancellationToken);
}
// Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed class RevisionConflictExceptionHandler : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken cancellationToken)
{
if (exception is not DbUpdateConcurrencyException) return false;
context.Response.StatusCode = StatusCodes.Status409Conflict;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = StatusCodes.Status409Conflict,
Title = "The record changed",
Detail = "Your draft was not saved. Reload and review the current record before saving again.",
Extensions = { ["code"] = "revision_conflict" },
}, cancellationToken);
return true;
}
}