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