From 46ff9454a86539324b26905dc2f3a5c9ca5f5923 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 18 Jul 2026 00:39:53 +0200 Subject: [PATCH] feat(career): relational projection and backfill for the master profile Phase 3, service layer. CareerProfileService now maintains the relational children as the source of truth for structured career data, with the StructuredCvProfile blob kept as a derived projection. - SaveVersionAsync additionally syncs the relational children (replace-all, preserving ItemKeys from the blob item ids; SortOrder = array position) and the LongTailJson (contact, summary, interests, other sections, metadata). - New LoadStructuredAsync reads the master profile from the relational children, lazily backfilling from the ProfileJson blob for profiles that predate Phase 3. - CareerProfileMapper: the two-way projection between relational rows and StructuredCvProfile. Tests (+5): round-trip through relational, item-key preservation, wholesale child replacement (no orphans), backfill from a pre-Phase-3 blob, empty profile. Co-Authored-By: Claude Opus 4.8 --- .../CareerProfileServiceTests.cs | 111 ++++++++++++ JobTrackerApi/Services/CareerProfileMapper.cs | 163 ++++++++++++++++++ .../Services/CareerProfileService.cs | 59 ++++++- 3 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 JobTrackerApi/Services/CareerProfileMapper.cs diff --git a/JobTrackerApi.Tests/CareerProfileServiceTests.cs b/JobTrackerApi.Tests/CareerProfileServiceTests.cs index e018fec..f0b726b 100644 --- a/JobTrackerApi.Tests/CareerProfileServiceTests.cs +++ b/JobTrackerApi.Tests/CareerProfileServiceTests.cs @@ -98,4 +98,115 @@ public sealed class CareerProfileServiceTests Assert.Equal("2020-01", saved.Jobs[0].StartDate); Assert.Null(saved.Jobs[0].EndDate); } + + // --- Phase 3: relational projection -------------------------------------------------------- + + private static StructuredCvProfile RichProfile() => new() + { + Contact = { FullName = "Ada Lovelace", Email = "ada@example.com", Headline = "Engineer" }, + Summary = { "First", "Second" }, + Interests = { "Chess" }, + Jobs = + { + new StructuredCvJob { Title = "Senior Eng", Company = "Acme", Start = "Jan 2020", End = "Present", IsCurrent = true, Bullets = { "Built X" }, Skills = { "C#" } }, + new StructuredCvJob { Title = "Eng", Company = "Beta", Start = "Jan 2018", End = "Dec 2019", Bullets = { "Built Y" } }, + }, + Education = { new StructuredCvEducation { Qualification = "BSc", Institution = "Uni", Details = { "Honours" } } }, + Skills = { "C#", "SQL", "Azure" }, + Projects = { new StructuredCvProject { Name = "Proj", Role = "Lead", Skills = { "React" } } }, + Certifications = { new StructuredCvCertification { Name = "AZ-204", Issuer = "MS", Date = "Mar 2021" } }, + Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } }, + }; + + [Fact] + public async Task Save_then_load_round_trips_through_the_relational_children() + { + await using var db = NewContext("user-1"); + var service = new CareerProfileService(db); + + await service.SaveVersionAsync("user-1", RichProfile(), "manual", default); + var loaded = await service.LoadStructuredAsync("user-1", default); + + Assert.Equal("Ada Lovelace", loaded.Contact.FullName); + Assert.Equal(new[] { "First", "Second" }, loaded.Summary); + Assert.Equal(new[] { "Chess" }, loaded.Interests); + Assert.Equal(2, loaded.Jobs.Count); + Assert.Equal("Senior Eng", loaded.Jobs[0].Title); // order preserved + Assert.True(loaded.Jobs[0].IsCurrent); + Assert.Equal(new[] { "Built X" }, loaded.Jobs[0].Bullets); + Assert.Equal(new[] { "C#" }, loaded.Jobs[0].Skills); + Assert.Equal("2020-01", loaded.Jobs[0].StartDate); + Assert.Single(loaded.Education); + Assert.Equal(new[] { "Honours" }, loaded.Education[0].Details); + Assert.Equal(new[] { "C#", "SQL", "Azure" }, loaded.Skills); + Assert.Equal("Proj", loaded.Projects[0].Name); + Assert.Equal("AZ-204", loaded.Certifications[0].Name); + Assert.Equal("English", loaded.Languages[0].Name); + } + + [Fact] + public async Task Save_preserves_item_keys_so_children_can_be_referenced_across_edits() + { + await using var db = NewContext("user-1"); + var service = new CareerProfileService(db); + var profile = RichProfile(); + + await service.SaveVersionAsync("user-1", profile, "manual", default); + var jobKey = profile.Jobs[0].Id; + var loaded = await service.LoadStructuredAsync("user-1", default); + + Assert.False(string.IsNullOrWhiteSpace(jobKey)); + Assert.Equal(jobKey, loaded.Jobs[0].Id); + // The relational row carries the same key. + var expRow = await db.CareerExperiences.IgnoreQueryFilters().FirstAsync(x => x.Title == "Senior Eng"); + Assert.Equal(jobKey, expRow.ItemKey); + } + + [Fact] + public async Task Save_replaces_children_wholesale_no_orphans() + { + await using var db = NewContext("user-1"); + var service = new CareerProfileService(db); + + await service.SaveVersionAsync("user-1", RichProfile(), "manual", default); // 2 jobs + var trimmed = new StructuredCvProfile { Jobs = { new StructuredCvJob { Title = "Only role", Company = "Solo" } } }; + await service.SaveVersionAsync("user-1", trimmed, "manual", default); // 1 job + + var experiences = await db.CareerExperiences.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync(); + Assert.Single(experiences); + Assert.Equal("Only role", experiences[0].Title); + // Other section children are cleared too. + Assert.Empty(await db.CareerSkills.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync()); + } + + [Fact] + public async Task Load_backfills_relational_children_from_a_pre_phase3_blob() + { + await using var db = NewContext("user-1"); + // Simulate a profile that predates Phase 3: a CareerProfile with a ProfileJson blob but no + // relational children. + var blob = StructuredCvProfileJson.Serialize(RichProfile()); + db.CareerProfiles.Add(new CareerProfile { OwnerUserId = "user-1", ProfileJson = blob, Version = 3 }); + await db.SaveChangesAsync(); + Assert.Empty(await db.CareerExperiences.IgnoreQueryFilters().ToListAsync()); + + var service = new CareerProfileService(db); + var loaded = await service.LoadStructuredAsync("user-1", default); + + Assert.Equal(2, loaded.Jobs.Count); + // The backfill materialized the relational rows. + Assert.Equal(2, await db.CareerExperiences.IgnoreQueryFilters().CountAsync(x => x.OwnerUserId == "user-1")); + } + + [Fact] + public async Task Load_returns_empty_profile_for_a_user_with_none() + { + await using var db = NewContext("user-1"); + var service = new CareerProfileService(db); + + var loaded = await service.LoadStructuredAsync("user-1", default); + + Assert.Empty(loaded.Jobs); + Assert.Empty(loaded.Skills); + } } diff --git a/JobTrackerApi/Services/CareerProfileMapper.cs b/JobTrackerApi/Services/CareerProfileMapper.cs new file mode 100644 index 0000000..96d5740 --- /dev/null +++ b/JobTrackerApi/Services/CareerProfileMapper.cs @@ -0,0 +1,163 @@ +using System.Text.Json; +using JobTrackerApi.Data; +using JobTrackerApi.Models; + +namespace JobTrackerApi.Services; + +// Phase 3: two-way projection between the relational CareerProfile children and the +// StructuredCvProfile shape (the derived blob + the frontend/AI edit unit). +// See docs/architecture/career-profile-model.md. +// +// Relational holds: Experience, Education, Skill, Project, Certification, Language. +// LongTailJson holds the rest of StructuredCvProfile: Contact, Summary, Interests, OtherSections, +// Sections, Metadata (and future achievements/orgs/pubs/courses). +public static class CareerProfileMapper +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + /// The non-relational parts of a StructuredCvProfile, stored as CareerProfile.LongTailJson. + public sealed class CareerLongTail + { + public string Version { get; set; } = "1"; + public StructuredCvMetadata Metadata { get; set; } = new(); + public StructuredCvContact Contact { get; set; } = new(); + public List Summary { get; set; } = new(); + public List Interests { get; set; } = new(); + public List OtherSections { get; set; } = new(); + public List Sections { get; set; } = new(); + } + + public static string SerializeLongTail(StructuredCvProfile p) + => JsonSerializer.Serialize(new CareerLongTail + { + Version = p.Version, + Metadata = p.Metadata, + Contact = p.Contact, + Summary = p.Summary, + Interests = p.Interests, + OtherSections = p.OtherSections, + Sections = p.Sections, + }, JsonOptions); + + private static CareerLongTail DeserializeLongTail(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new CareerLongTail(); + try { return JsonSerializer.Deserialize(json, JsonOptions) ?? new CareerLongTail(); } + catch { return new CareerLongTail(); } + } + + // --- StructuredCvProfile -> relational rows (insert) ------------------------------------------ + + public static void AddChildren(JobTrackerContext db, int careerProfileId, string ownerUserId, StructuredCvProfile p) + { + int order = 0; + foreach (var j in p.Jobs) + db.CareerExperiences.Add(new CareerExperience + { + CareerProfileId = careerProfileId, OwnerUserId = ownerUserId, ItemKey = KeyOf(j.Id), SortOrder = order++, + Title = j.Title, Company = j.Company, Location = j.Location, + Start = j.Start, End = j.End, StartDate = j.StartDate, EndDate = j.EndDate, IsCurrent = j.IsCurrent, + Bullets = j.Bullets, Skills = j.Skills, + }); + + order = 0; + foreach (var e in p.Education) + db.CareerEducations.Add(new CareerEducation + { + CareerProfileId = careerProfileId, OwnerUserId = ownerUserId, ItemKey = KeyOf(e.Id), SortOrder = order++, + Qualification = e.Qualification, QualificationLevel = e.QualificationLevel, Institution = e.Institution, + Location = e.Location, Start = e.Start, End = e.End, StartDate = e.StartDate, EndDate = e.EndDate, + Details = e.Details, + }); + + order = 0; + foreach (var s in p.Skills) + db.CareerSkills.Add(new CareerSkill + { + CareerProfileId = careerProfileId, OwnerUserId = ownerUserId, ItemKey = KeyOf(null), SortOrder = order++, + Name = s, + }); + + order = 0; + foreach (var pr in p.Projects) + db.CareerProjects.Add(new CareerProject + { + CareerProfileId = careerProfileId, OwnerUserId = ownerUserId, ItemKey = KeyOf(pr.Id), SortOrder = order++, + Name = pr.Name, Role = pr.Role, Location = pr.Location, + Start = pr.Start, End = pr.End, StartDate = pr.StartDate, EndDate = pr.EndDate, + Bullets = pr.Bullets, Skills = pr.Skills, + }); + + order = 0; + foreach (var c in p.Certifications) + db.CareerCertifications.Add(new CareerCertification + { + CareerProfileId = careerProfileId, OwnerUserId = ownerUserId, ItemKey = KeyOf(c.Id), SortOrder = order++, + Name = c.Name, Issuer = c.Issuer, Location = c.Location, Date = c.Date, DateNormalized = c.DateNormalized, + Details = c.Details, + }); + + order = 0; + foreach (var l in p.Languages) + db.CareerLanguages.Add(new CareerLanguage + { + CareerProfileId = careerProfileId, OwnerUserId = ownerUserId, ItemKey = KeyOf(null), SortOrder = order++, + Name = l.Name, Level = l.Level, Notes = l.Notes, + }); + } + + // --- relational rows -> StructuredCvProfile (read) ------------------------------------------- + + public static StructuredCvProfile ToStructured( + string? longTailJson, + List experiences, + List education, + List skills, + List projects, + List certifications, + List languages) + { + var tail = DeserializeLongTail(longTailJson); + return new StructuredCvProfile + { + Version = tail.Version, + Metadata = tail.Metadata, + Contact = tail.Contact, + Summary = tail.Summary, + Interests = tail.Interests, + OtherSections = tail.OtherSections, + Sections = tail.Sections, + Jobs = experiences.Select(x => new StructuredCvJob + { + Id = NullIfEmpty(x.ItemKey), Title = x.Title, Company = x.Company, Location = x.Location, + Start = x.Start, End = x.End, StartDate = x.StartDate, EndDate = x.EndDate, IsCurrent = x.IsCurrent, + Bullets = x.Bullets, Skills = x.Skills, + }).ToList(), + Education = education.Select(x => new StructuredCvEducation + { + Id = NullIfEmpty(x.ItemKey), Qualification = x.Qualification, QualificationLevel = x.QualificationLevel, + Institution = x.Institution, Location = x.Location, Start = x.Start, End = x.End, + StartDate = x.StartDate, EndDate = x.EndDate, Details = x.Details, + }).ToList(), + Skills = skills.Select(x => x.Name ?? string.Empty).Where(x => x.Length > 0).ToList(), + Projects = projects.Select(x => new StructuredCvProject + { + Id = NullIfEmpty(x.ItemKey), Name = x.Name, Role = x.Role, Location = x.Location, + Start = x.Start, End = x.End, StartDate = x.StartDate, EndDate = x.EndDate, + Bullets = x.Bullets, Skills = x.Skills, + }).ToList(), + Certifications = certifications.Select(x => new StructuredCvCertification + { + Id = NullIfEmpty(x.ItemKey), Name = x.Name, Issuer = x.Issuer, Location = x.Location, + Date = x.Date, DateNormalized = x.DateNormalized, Details = x.Details, + }).ToList(), + Languages = languages.Select(x => new StructuredCvLanguage + { + Name = x.Name, Level = x.Level, Notes = x.Notes, + }).ToList(), + }; + } + + private static string KeyOf(string? id) => string.IsNullOrWhiteSpace(id) ? Guid.NewGuid().ToString("N")[..12] : id; + private static string? NullIfEmpty(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; +} diff --git a/JobTrackerApi/Services/CareerProfileService.cs b/JobTrackerApi/Services/CareerProfileService.cs index 46ce17e..08f14ed 100644 --- a/JobTrackerApi/Services/CareerProfileService.cs +++ b/JobTrackerApi/Services/CareerProfileService.cs @@ -13,9 +13,15 @@ namespace JobTrackerApi.Services; 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, and + // 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 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 LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken); } public sealed class CareerProfileService : ICareerProfileService @@ -54,8 +60,11 @@ public sealed class CareerProfileService : ICareerProfileService 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, @@ -70,6 +79,54 @@ public sealed class CareerProfileService : ICareerProfileService return profile; } + public async Task 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); + } + + // 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)