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