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;
|
||||
}
|
||||
Reference in New Issue
Block a user