feat(career): add career profile versioning
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:
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user