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)