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
+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)