Files
jobtrackingapp/JobTrackerApi/Services/CareerProfileMapper.cs
T
cesnimda 46ff9454a8
CI and Deploy / test (push) Failing after 1m53s
CI and Deploy / deploy (push) Has been skipped
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>
2026-07-18 00:39:53 +02:00

164 lines
7.5 KiB
C#

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;
}