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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>The non-relational parts of a StructuredCvProfile, stored as CareerProfile.LongTailJson.</summary>
|
||||
public sealed class CareerLongTail
|
||||
{
|
||||
public string Version { get; set; } = "1";
|
||||
public StructuredCvMetadata Metadata { get; set; } = new();
|
||||
public StructuredCvContact Contact { get; set; } = new();
|
||||
public List<string> Summary { get; set; } = new();
|
||||
public List<string> Interests { get; set; } = new();
|
||||
public List<StructuredCvOtherSection> OtherSections { get; set; } = new();
|
||||
public List<StructuredCvSection> 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<CareerLongTail>(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<CareerExperience> experiences,
|
||||
List<CareerEducation> education,
|
||||
List<CareerSkill> skills,
|
||||
List<CareerProject> projects,
|
||||
List<CareerCertification> certifications,
|
||||
List<CareerLanguage> 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;
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
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<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);
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
Reference in New Issue
Block a user