feat(career): add career profile versioning
CI and Deploy / test (push) Failing after 1m54s
CI and Deploy / deploy (push) Has been skipped

Phase 3, version history (list + restore). CareerProfileVersions was already
populated on every save; this makes it usable.

- ICareerProfileService.ListVersionsAsync — the append-only history, newest first,
  with the current version flagged.
- RestoreVersionAsync — reapplies a past snapshot NON-DESTRUCTIVELY: it is re-saved
  as a new version, so the current state stays in history and the restore is itself
  reversible. Syncs the relational children + blob projection like any save.
- Endpoints: GET /career/profile/versions, POST /career/profile/versions/{v}/restore.

Tests (+4): versions listed newest-first with current flagged; restore reapplies
an old snapshot as a new version (history preserved, reversible); restore of a
missing version returns null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-18 00:58:18 +02:00
parent 2b57d65715
commit f1bf92a4e0
3 changed files with 114 additions and 0 deletions
@@ -209,4 +209,50 @@ public sealed class CareerProfileServiceTests
Assert.Empty(loaded.Jobs); Assert.Empty(loaded.Jobs);
Assert.Empty(loaded.Skills); Assert.Empty(loaded.Skills);
} }
[Fact]
public async Task Versions_are_listed_newest_first_with_the_current_flagged()
{
await using var db = NewContext("user-1");
var service = new CareerProfileService(db);
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v1" } }, "upload", default);
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "v2" } }, "improve", default);
var versions = await service.ListVersionsAsync("user-1", default);
Assert.Equal(new[] { 2, 1 }, versions.Select(v => v.Version));
Assert.True(versions[0].IsCurrent);
Assert.False(versions[1].IsCurrent);
Assert.Equal("improve", versions[0].Source);
}
[Fact]
public async Task Restore_reapplies_an_old_snapshot_non_destructively()
{
await using var db = NewContext("user-1");
var service = new CareerProfileService(db);
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "original" } }, "upload", default); // v1
await service.SaveVersionAsync("user-1", new StructuredCvProfile { Summary = { "edited" } }, "manual", default); // v2
var restored = await service.RestoreVersionAsync("user-1", 1, default);
Assert.NotNull(restored);
Assert.Equal(new[] { "original" }, restored!.Summary);
// Restore is a new version (v3), so history is preserved and the restore is reversible.
var versions = await service.ListVersionsAsync("user-1", default);
Assert.Equal(new[] { 3, 2, 1 }, versions.Select(v => v.Version));
Assert.Equal("restore:v1", versions[0].Source);
var loaded = await service.LoadStructuredAsync("user-1", default);
Assert.Equal(new[] { "original" }, loaded.Summary);
}
[Fact]
public async Task Restore_of_a_missing_version_returns_null()
{
await using var db = NewContext("user-1");
var service = new CareerProfileService(db);
await service.SaveVersionAsync("user-1", new StructuredCvProfile(), "upload", default);
Assert.Null(await service.RestoreVersionAsync("user-1", 99, default));
}
} }
@@ -76,6 +76,36 @@ public sealed class CareerProfileController : ControllerBase
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken); var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
return Ok(CareerCompleteness.Evaluate(profile)); return Ok(CareerCompleteness.Evaluate(profile));
} }
/// <summary>The append-only version history, newest first.</summary>
[HttpGet("versions")]
public async Task<ActionResult<IReadOnlyList<CareerProfileVersionInfo>>> Versions(CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return Ok(await _career.ListVersionsAsync(user.Id, cancellationToken));
}
/// <summary>
/// Restores a past version, non-destructively (re-saved as a new version so the restore is
/// itself reversible). Returns the restored profile + refreshed completeness.
/// </summary>
[HttpPost("versions/{version:int}/restore")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<CareerProfileDto>> Restore([FromRoute] int version, 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.");
var restored = await _career.RestoreVersionAsync(user.Id, version, cancellationToken);
if (restored is null) return NotFound($"Version {version} was not found.");
user.ProfileCvStructureJson = StructuredCvProfileJson.Serialize(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));
}
} }
public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText); public sealed record CareerProfileSaveRequest(StructuredCvProfile? Profile, string? CvText);
@@ -22,8 +22,18 @@ public interface ICareerProfileService
// rows are empty but a ProfileJson blob exists (a profile that predates Phase 3), it is // 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. // backfilled from the blob first. Returns an empty profile if the user has none.
Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken); Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken);
// The append-only version history, newest first.
Task<IReadOnlyList<CareerProfileVersionInfo>> ListVersionsAsync(string ownerUserId, CancellationToken cancellationToken);
// 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);
} }
public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent);
public sealed class CareerProfileService : ICareerProfileService public sealed class CareerProfileService : ICareerProfileService
{ {
private readonly JobTrackerContext _db; private readonly JobTrackerContext _db;
@@ -110,6 +120,34 @@ public sealed class CareerProfileService : ICareerProfileService
return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages); return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages);
} }
public async Task<IReadOnlyList<CareerProfileVersionInfo>> ListVersionsAsync(string ownerUserId, CancellationToken cancellationToken)
{
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return Array.Empty<CareerProfileVersionInfo>();
var versions = await _db.CareerProfileVersions
.Where(x => x.CareerProfileId == profile.Id)
.OrderByDescending(x => x.Version)
.Select(x => new { x.Version, x.Source, x.CreatedAtUtc })
.ToListAsync(cancellationToken);
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)
{
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return null;
var target = await _db.CareerProfileVersions.FirstOrDefaultAsync(x => x.CareerProfileId == profile.Id && x.Version == version, cancellationToken);
if (target is null) return null;
// 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.Deserialize(target.ProfileJson);
return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken);
}
// Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the // Replace-all: the profile is small and edited as a whole, so wiping and re-inserting the
// children is simpler and safer than diffing. ItemKeys are preserved from the StructuredCvProfile // children is simpler and safer than diffing. ItemKeys are preserved from the StructuredCvProfile
// item ids so references survive; SortOrder is the array position. // item ids so references survive; SortOrder is the array position.