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