feat(career): relational projection and backfill for the master profile
CI and Deploy / test (push) Failing after 1m53s
CI and Deploy / deploy (push) Has been skipped

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:
cesnimda
2026-07-18 00:39:53 +02:00
parent 3a4c8fbc10
commit 46ff9454a8
3 changed files with 332 additions and 1 deletions
@@ -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;
}
+58 -1
View File
@@ -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)