Files
jobtrackingapp/JobTrackerApi/Services/CareerProfileService.cs
T
cesnimda f1bf92a4e0
CI and Deploy / test (push) Failing after 1m54s
CI and Deploy / deploy (push) Has been skipped
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>
2026-07-18 00:58:18 +02:00

265 lines
13 KiB
C#

using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// Career Workspace foundation (see docs/career-workspace-implementation-roadmap.md, Phase F1).
// Bounded to the profile/CV domain -- job tracking is untouched. Every existing read path still
// goes through ApplicationUser.ProfileCvStructureJson (dual-write window); this service is the
// single place stable item IDs and normalized dates get assigned, and where profile history is
// captured so it's never silently overwritten on the next rebuild/improve/upload.
public interface ICareerProfileService
{
// Mutates the given profile in place (assigns missing item IDs + normalized dates), persists
// it as the current CareerProfile snapshot plus an append-only CareerProfileVersion row, syncs
// the relational children (Experience/Education/Skill/Project/Certification/Language), and
// returns the same profile so the caller can go on to serialize it into the legacy column.
Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken);
// Reads the master profile from the relational children (the source of truth). If the relational
// 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;
public CareerProfileService(JobTrackerContext db)
{
_db = db;
}
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
{
AssignStableIds(profile);
NormalizeDates(profile);
var json = StructuredCvProfileJson.Serialize(profile);
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
{
existing = new CareerProfile
{
OwnerUserId = ownerUserId,
ProfileJson = json,
Version = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
_db.CareerProfiles.Add(existing);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
existing.UpdatedAtUtc = DateTimeOffset.UtcNow;
}
existing.LongTailJson = CareerProfileMapper.SerializeLongTail(profile);
await _db.SaveChangesAsync(cancellationToken);
await SyncRelationalChildrenAsync(existing.Id, ownerUserId, profile, cancellationToken);
_db.CareerProfileVersions.Add(new CareerProfileVersion
{
OwnerUserId = ownerUserId,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json,
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await _db.SaveChangesAsync(cancellationToken);
return profile;
}
public async Task<StructuredCvProfile> LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken)
{
var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (profile is null) return new StructuredCvProfile();
var hasRelational = await _db.CareerExperiences.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|| await _db.CareerEducations.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|| await _db.CareerSkills.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|| await _db.CareerProjects.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|| await _db.CareerCertifications.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken)
|| await _db.CareerLanguages.AnyAsync(x => x.CareerProfileId == profile.Id, cancellationToken);
// Backfill a pre-Phase-3 profile from its blob, once, before reading relationally.
if (!hasRelational && !string.IsNullOrWhiteSpace(profile.ProfileJson))
{
var fromBlob = StructuredCvProfileJson.Deserialize(profile.ProfileJson);
AssignStableIds(fromBlob);
NormalizeDates(fromBlob);
await SyncRelationalChildrenAsync(profile.Id, ownerUserId, fromBlob, cancellationToken);
}
var experiences = await _db.CareerExperiences.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var education = await _db.CareerEducations.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var skills = await _db.CareerSkills.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var projects = await _db.CareerProjects.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var certifications = await _db.CareerCertifications.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
var languages = await _db.CareerLanguages.Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken);
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.
private async Task SyncRelationalChildrenAsync(int careerProfileId, string ownerUserId, StructuredCvProfile source, CancellationToken cancellationToken)
{
_db.CareerExperiences.RemoveRange(_db.CareerExperiences.Where(x => x.CareerProfileId == careerProfileId));
_db.CareerEducations.RemoveRange(_db.CareerEducations.Where(x => x.CareerProfileId == careerProfileId));
_db.CareerSkills.RemoveRange(_db.CareerSkills.Where(x => x.CareerProfileId == careerProfileId));
_db.CareerProjects.RemoveRange(_db.CareerProjects.Where(x => x.CareerProfileId == careerProfileId));
_db.CareerCertifications.RemoveRange(_db.CareerCertifications.Where(x => x.CareerProfileId == careerProfileId));
_db.CareerLanguages.RemoveRange(_db.CareerLanguages.Where(x => x.CareerProfileId == careerProfileId));
await _db.SaveChangesAsync(cancellationToken);
CareerProfileMapper.AddChildren(_db, careerProfileId, ownerUserId, source);
await _db.SaveChangesAsync(cancellationToken);
}
private static void AssignStableIds(StructuredCvProfile profile)
{
foreach (var job in profile.Jobs)
{
if (string.IsNullOrWhiteSpace(job.Id)) job.Id = NewItemId();
}
foreach (var education in profile.Education)
{
if (string.IsNullOrWhiteSpace(education.Id)) education.Id = NewItemId();
}
foreach (var certification in profile.Certifications)
{
if (string.IsNullOrWhiteSpace(certification.Id)) certification.Id = NewItemId();
}
foreach (var project in profile.Projects)
{
if (string.IsNullOrWhiteSpace(project.Id)) project.Id = NewItemId();
}
}
private static string NewItemId() => Guid.NewGuid().ToString("N")[..12];
private static void NormalizeDates(StructuredCvProfile profile)
{
foreach (var job in profile.Jobs)
{
job.StartDate = CvDateNormalizer.TryParseYearMonth(job.Start);
job.EndDate = job.IsCurrent ? null : CvDateNormalizer.TryParseYearMonth(job.End);
}
foreach (var education in profile.Education)
{
education.StartDate = CvDateNormalizer.TryParseYearMonth(education.Start);
education.EndDate = CvDateNormalizer.TryParseYearMonth(education.End);
}
foreach (var certification in profile.Certifications)
{
certification.DateNormalized = CvDateNormalizer.TryParseYearMonth(certification.Date);
}
foreach (var project in profile.Projects)
{
project.StartDate = CvDateNormalizer.TryParseYearMonth(project.Start);
project.EndDate = CvDateNormalizer.TryParseYearMonth(project.End);
}
}
}
// Best-effort free-string -> "YYYY-MM" parser. Never throws, never loses data: the original
// free-string field is always kept alongside whatever this returns (null on anything it can't
// confidently parse -- callers must not treat null as "no date", only as "unparsed").
public static class CvDateNormalizer
{
private static readonly Dictionary<string, int> MonthNames = new(StringComparer.OrdinalIgnoreCase)
{
["jan"] = 1, ["january"] = 1,
["feb"] = 2, ["february"] = 2,
["mar"] = 3, ["march"] = 3,
["apr"] = 4, ["april"] = 4,
["may"] = 5,
["jun"] = 6, ["june"] = 6,
["jul"] = 7, ["july"] = 7,
["aug"] = 8, ["august"] = 8,
["sep"] = 9, ["sept"] = 9, ["september"] = 9,
["oct"] = 10, ["october"] = 10,
["nov"] = 11, ["november"] = 11,
["dec"] = 12, ["december"] = 12,
};
public static string? TryParseYearMonth(string? raw)
{
var value = (raw ?? string.Empty).Trim();
if (value.Length == 0) return null;
if (value.Equals("present", StringComparison.OrdinalIgnoreCase) || value.Equals("current", StringComparison.OrdinalIgnoreCase)) return null;
// "2020" -> January is an assumption we don't want to make silently; year-only stays unparsed.
var monthYear = Regex.Match(value, @"^(?<month>[A-Za-z]+)\.?\s+(?<year>\d{4})$");
if (monthYear.Success && MonthNames.TryGetValue(monthYear.Groups["month"].Value, out var month))
{
return $"{monthYear.Groups["year"].Value}-{month:D2}";
}
var slash = Regex.Match(value, @"^(?<month>\d{1,2})/(?<year>\d{4})$");
if (slash.Success)
{
var m = int.Parse(slash.Groups["month"].Value);
if (m is >= 1 and <= 12) return $"{slash.Groups["year"].Value}-{m:D2}";
}
var isoLike = Regex.Match(value, @"^(?<year>\d{4})-(?<month>\d{1,2})$");
if (isoLike.Success)
{
var m = int.Parse(isoLike.Groups["month"].Value);
if (m is >= 1 and <= 12) return $"{isoLike.Groups["year"].Value}-{m:D2}";
}
return null;
}
}