From f1bf92a4e0d1ab3492f23387cb0bc6ddd94d70fe Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 18 Jul 2026 00:58:18 +0200 Subject: [PATCH] feat(career): add career profile versioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../CareerProfileServiceTests.cs | 46 +++++++++++++++++++ .../Controllers/CareerProfileController.cs | 30 ++++++++++++ .../Services/CareerProfileService.cs | 38 +++++++++++++++ 3 files changed, 114 insertions(+) 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.