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
@@ -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.