feat(cv): redesign document editing with revision-safe letters and writing tools
This commit is contained in:
@@ -42,7 +42,8 @@ public sealed record CoverLetterDto(
|
||||
int CurrentVersion,
|
||||
IReadOnlyList<CoverLetterVersionDto> Versions,
|
||||
int AiSuggestionCount,
|
||||
int Revision = 1);
|
||||
int Revision = 1,
|
||||
CoverLetterDocumentSettings? DocumentSettings = null);
|
||||
|
||||
public interface IApplicationAssetsService
|
||||
{
|
||||
@@ -51,8 +52,10 @@ 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, int? expectedVersion, CancellationToken ct);
|
||||
Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct, CoverLetterDocumentSettings? documentSettings = null);
|
||||
Task<CoverLetterDto?> RestoreCoverLetterAsync(string ownerUserId, int jobApplicationId, int version, int? expectedVersion, CancellationToken ct);
|
||||
Task<CoverLetterRenderDto?> PreviewCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, CoverLetterDocumentSettings? settings, CancellationToken ct);
|
||||
Task<CoverLetterRenderDto?> RenderSavedCoverLetterAsync(string ownerUserId, int jobApplicationId, int? expectedVersion, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
@@ -60,12 +63,14 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly ICvVariantService _variants;
|
||||
private readonly IApplicationIntelligenceService _intelligence;
|
||||
private readonly CoverLetterDocumentRenderer _letters;
|
||||
|
||||
public ApplicationAssetsService(JobTrackerContext db, ICvVariantService variants, IApplicationIntelligenceService intelligence)
|
||||
public ApplicationAssetsService(JobTrackerContext db, ICvVariantService variants, IApplicationIntelligenceService intelligence, CoverLetterDocumentRenderer? letters = null)
|
||||
{
|
||||
_db = db;
|
||||
_variants = variants;
|
||||
_intelligence = intelligence;
|
||||
_letters = letters ?? new CoverLetterDocumentRenderer();
|
||||
}
|
||||
|
||||
// ---------- Part 1: CV variant integration ----------
|
||||
@@ -235,10 +240,8 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
return await BuildCoverLetterAsync(ownerUserId, job, ct);
|
||||
}
|
||||
|
||||
// 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, int? expectedVersion, CancellationToken ct)
|
||||
// Only approved text becomes a version; generating an AI suggestion does not save it.
|
||||
public async Task<CoverLetterDto?> SaveCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, string source, string? aiAction, int? expectedVersion, CancellationToken ct, CoverLetterDocumentSettings? documentSettings = null)
|
||||
{
|
||||
var job = await _db.JobApplications
|
||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||
@@ -251,31 +254,21 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
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))
|
||||
var sameSettings = documentSettings is null || CoverLetterDocumentSettingsJson.Serialize(documentSettings)
|
||||
== CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
|
||||
if (string.Equals(next, current, StringComparison.Ordinal) && sameSettings)
|
||||
{
|
||||
// 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);
|
||||
try { await _db.SaveChangesAsync(ct); }
|
||||
catch { _db.ChangeTracker.Clear(); throw; }
|
||||
return await BuildCoverLetterAsync(ownerUserId, job, ct);
|
||||
}
|
||||
|
||||
var version = await NextVersionAsync(ownerUserId, jobApplicationId, ct);
|
||||
_db.CoverLetterVersions.Add(new CoverLetterVersion
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
JobApplicationId = jobApplicationId,
|
||||
Version = version,
|
||||
Text = next,
|
||||
Source = CoverLetterSources.IsValid(source) ? source : CoverLetterSources.Manual,
|
||||
AiAction = string.IsNullOrWhiteSpace(aiAction) ? null : aiAction.Trim(),
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await StageCoverLetterAsync(_db, job, next.Length == 0 ? null : next, source, aiAction, ct, documentSettings);
|
||||
|
||||
job.CoverLetterText = next.Length == 0 ? null : next;
|
||||
// HasCoverLetter is derived from attachments elsewhere; a written draft counts too.
|
||||
if (next.Length > 0) job.HasCoverLetter = true;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
try { await _db.SaveChangesAsync(ct); }
|
||||
catch { _db.ChangeTracker.Clear(); throw; }
|
||||
return await BuildCoverLetterAsync(ownerUserId, job, ct);
|
||||
}
|
||||
|
||||
@@ -291,7 +284,23 @@ 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, expectedVersion, ct);
|
||||
return await SaveCoverLetterAsync(ownerUserId, jobApplicationId, snapshot.Text, CoverLetterSources.Restore, null, expectedVersion, ct,
|
||||
CoverLetterDocumentSettingsJson.Deserialize(snapshot.DocumentSettingsJson));
|
||||
}
|
||||
|
||||
public async Task<CoverLetterRenderDto?> PreviewCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, CoverLetterDocumentSettings? settings, CancellationToken ct)
|
||||
{
|
||||
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
|
||||
return job is null ? null : _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
|
||||
}
|
||||
|
||||
public async Task<CoverLetterRenderDto?> RenderSavedCoverLetterAsync(string ownerUserId, int jobApplicationId, int? expectedVersion, CancellationToken ct)
|
||||
{
|
||||
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
|
||||
if (job is null) return null;
|
||||
if (expectedVersion is null or < 1 || expectedVersion != job.Version)
|
||||
throw new DbUpdateConcurrencyException("The application changed. Save and review the current letter before exporting.");
|
||||
return _letters.Render(job.CoverLetterText, CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
|
||||
}
|
||||
|
||||
private async Task<CoverLetterDto> BuildCoverLetterAsync(string ownerUserId, JobApplication job, CancellationToken ct)
|
||||
@@ -312,16 +321,44 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
versions.Select(v => new CoverLetterVersionDto(
|
||||
v.Version, v.Source, v.AiAction, v.Text.Length, v.CreatedAtUtc, v.Version == current)).ToList(),
|
||||
aiCount,
|
||||
job.Version);
|
||||
job.Version,
|
||||
CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
|
||||
}
|
||||
|
||||
private async Task<int> NextVersionAsync(string ownerUserId, int jobApplicationId, CancellationToken ct)
|
||||
// Stage history and current text together; the caller owns the parent save and its revision check.
|
||||
internal static async Task StageCoverLetterAsync(JobTrackerContext db, JobApplication job, string? text,
|
||||
string source, string? aiAction, CancellationToken ct, CoverLetterDocumentSettings? settings = null)
|
||||
{
|
||||
var max = await _db.CoverLetterVersions.AsNoTracking()
|
||||
.Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId)
|
||||
.Select(v => (int?)v.Version)
|
||||
.MaxAsync(ct);
|
||||
return (max ?? 0) + 1;
|
||||
var currentSettings = CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
|
||||
var nextSettings = settings is null ? currentSettings : CoverLetterDocumentSettingsJson.Serialize(settings);
|
||||
if (string.Equals(job.CoverLetterText, text, StringComparison.Ordinal) && nextSettings == currentSettings) return;
|
||||
|
||||
var latest = db.Entry(job).State == EntityState.Added ? null : await db.CoverLetterVersions.AsNoTracking()
|
||||
.Where(v => v.OwnerUserId == job.OwnerUserId && v.JobApplicationId == job.Id)
|
||||
.OrderByDescending(v => v.Version).FirstOrDefaultAsync(ct);
|
||||
var version = latest?.Version ?? 0;
|
||||
|
||||
void Snapshot(string value, string snapshotSource, string? action, string? settingsJson) => db.CoverLetterVersions.Add(new CoverLetterVersion
|
||||
{
|
||||
OwnerUserId = job.OwnerUserId ?? string.Empty,
|
||||
JobApplication = job,
|
||||
Version = ++version,
|
||||
Text = value,
|
||||
Source = snapshotSource,
|
||||
AiAction = action,
|
||||
DocumentSettingsJson = settingsJson,
|
||||
CreatedAtUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
// Older generic/import writes could bypass history; preserve their current state before replacing it.
|
||||
if (latest is null ? !string.IsNullOrEmpty(job.CoverLetterText) || job.CoverLetterDocumentSettingsJson is not null
|
||||
: latest.Text != (job.CoverLetterText ?? string.Empty) || CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(latest.DocumentSettingsJson)) != currentSettings)
|
||||
Snapshot(job.CoverLetterText ?? string.Empty, CoverLetterSources.Legacy, null, job.CoverLetterDocumentSettingsJson);
|
||||
Snapshot(text ?? string.Empty, CoverLetterSources.IsValid(source) ? source : CoverLetterSources.Manual,
|
||||
string.IsNullOrWhiteSpace(aiAction) ? null : aiAction.Trim(), nextSettings);
|
||||
job.CoverLetterText = text;
|
||||
if (settings is not null) job.CoverLetterDocumentSettingsJson = nextSettings;
|
||||
if (!string.IsNullOrWhiteSpace(text)) job.HasCoverLetter = true;
|
||||
}
|
||||
|
||||
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
|
||||
|
||||
Reference in New Issue
Block a user