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:
@@ -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