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:
@@ -76,6 +76,36 @@ public sealed class CareerProfileController : ControllerBase
|
||||
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
|
||||
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);
|
||||
|
||||
@@ -22,8 +22,18 @@ public interface ICareerProfileService
|
||||
// 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.
|
||||
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
|
||||
{
|
||||
private readonly JobTrackerContext _db;
|
||||
@@ -110,6 +120,34 @@ public sealed class CareerProfileService : ICareerProfileService
|
||||
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
|
||||
// children is simpler and safer than diffing. ItemKeys are preserved from the StructuredCvProfile
|
||||
// item ids so references survive; SortOrder is the array position.
|
||||
|
||||
Reference in New Issue
Block a user