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)