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)