diff --git a/JobTrackerApi.Tests/CareerProfileServiceTests.cs b/JobTrackerApi.Tests/CareerProfileServiceTests.cs
index f0b726b..3e047eb 100644
--- a/JobTrackerApi.Tests/CareerProfileServiceTests.cs
+++ b/JobTrackerApi.Tests/CareerProfileServiceTests.cs
@@ -209,4 +209,50 @@ public sealed class CareerProfileServiceTests
Assert.Empty(loaded.Jobs);
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));
+ }
}
diff --git a/JobTrackerApi/Controllers/CareerProfileController.cs b/JobTrackerApi/Controllers/CareerProfileController.cs
index ccbc1f9..73b23dc 100644
--- a/JobTrackerApi/Controllers/CareerProfileController.cs
+++ b/JobTrackerApi/Controllers/CareerProfileController.cs
@@ -76,6 +76,36 @@ public sealed class CareerProfileController : ControllerBase
var profile = await _career.LoadStructuredAsync(user.Id, cancellationToken);
return Ok(CareerCompleteness.Evaluate(profile));
}
+
+ /// The append-only version history, newest first.
+ [HttpGet("versions")]
+ public async Task>> Versions(CancellationToken cancellationToken)
+ {
+ var user = await _users.GetUserAsync(User);
+ if (user is null) return Unauthorized();
+ return Ok(await _career.ListVersionsAsync(user.Id, cancellationToken));
+ }
+
+ ///
+ /// Restores a past version, non-destructively (re-saved as a new version so the restore is
+ /// itself reversible). Returns the restored profile + refreshed completeness.
+ ///
+ [HttpPost("versions/{version:int}/restore")]
+ [Authorize(AuthenticationSchemes = "local")]
+ public async Task> 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);
diff --git a/JobTrackerApi/Services/CareerProfileService.cs b/JobTrackerApi/Services/CareerProfileService.cs
index 08f14ed..d7a80a2 100644
--- a/JobTrackerApi/Services/CareerProfileService.cs
+++ b/JobTrackerApi/Services/CareerProfileService.cs
@@ -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 LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken);
+
+ // The append-only version history, newest first.
+ Task> 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 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> ListVersionsAsync(string ownerUserId, CancellationToken cancellationToken)
+ {
+ var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
+ if (profile is null) return Array.Empty();
+
+ 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 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.