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