Files
jobtrackingapp/JobTrackerApi/Services/CareerProfileService.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

227 lines
11 KiB
C#

using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
// Career Workspace foundation (see docs/career-workspace-implementation-roadmap.md, Phase F1).
// Bounded to the profile/CV domain -- job tracking is untouched. Every existing read path still
// goes through ApplicationUser.ProfileCvStructureJson (dual-write window); this service is the
// single place stable item IDs and normalized dates get assigned, and where profile history is
// captured so it's never silently overwritten on the next rebuild/improve/upload.
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, 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
{
private readonly JobTrackerContext _db;
public CareerProfileService(JobTrackerContext db)
{
_db = db;
}
public async Task<StructuredCvProfile> SaveVersionAsync(string ownerUserId, StructuredCvProfile profile, string source, CancellationToken cancellationToken)
{
AssignStableIds(profile);
NormalizeDates(profile);
var json = StructuredCvProfileJson.Serialize(profile);
var existing = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
{
existing = new CareerProfile
{
OwnerUserId = ownerUserId,
ProfileJson = json,
Version = 1,
CreatedAtUtc = DateTimeOffset.UtcNow,
UpdatedAtUtc = DateTimeOffset.UtcNow,
};
_db.CareerProfiles.Add(existing);
}
else
{
existing.ProfileJson = json;
existing.Version += 1;
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,
CareerProfileId = existing.Id,
Version = existing.Version,
ProfileJson = json,
Source = string.IsNullOrWhiteSpace(source) ? "manual" : source.Trim(),
CreatedAtUtc = DateTimeOffset.UtcNow,
});
await _db.SaveChangesAsync(cancellationToken);
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)
{
if (string.IsNullOrWhiteSpace(job.Id)) job.Id = NewItemId();
}
foreach (var education in profile.Education)
{
if (string.IsNullOrWhiteSpace(education.Id)) education.Id = NewItemId();
}
foreach (var certification in profile.Certifications)
{
if (string.IsNullOrWhiteSpace(certification.Id)) certification.Id = NewItemId();
}
foreach (var project in profile.Projects)
{
if (string.IsNullOrWhiteSpace(project.Id)) project.Id = NewItemId();
}
}
private static string NewItemId() => Guid.NewGuid().ToString("N")[..12];
private static void NormalizeDates(StructuredCvProfile profile)
{
foreach (var job in profile.Jobs)
{
job.StartDate = CvDateNormalizer.TryParseYearMonth(job.Start);
job.EndDate = job.IsCurrent ? null : CvDateNormalizer.TryParseYearMonth(job.End);
}
foreach (var education in profile.Education)
{
education.StartDate = CvDateNormalizer.TryParseYearMonth(education.Start);
education.EndDate = CvDateNormalizer.TryParseYearMonth(education.End);
}
foreach (var certification in profile.Certifications)
{
certification.DateNormalized = CvDateNormalizer.TryParseYearMonth(certification.Date);
}
foreach (var project in profile.Projects)
{
project.StartDate = CvDateNormalizer.TryParseYearMonth(project.Start);
project.EndDate = CvDateNormalizer.TryParseYearMonth(project.End);
}
}
}
// Best-effort free-string -> "YYYY-MM" parser. Never throws, never loses data: the original
// free-string field is always kept alongside whatever this returns (null on anything it can't
// confidently parse -- callers must not treat null as "no date", only as "unparsed").
public static class CvDateNormalizer
{
private static readonly Dictionary<string, int> MonthNames = new(StringComparer.OrdinalIgnoreCase)
{
["jan"] = 1, ["january"] = 1,
["feb"] = 2, ["february"] = 2,
["mar"] = 3, ["march"] = 3,
["apr"] = 4, ["april"] = 4,
["may"] = 5,
["jun"] = 6, ["june"] = 6,
["jul"] = 7, ["july"] = 7,
["aug"] = 8, ["august"] = 8,
["sep"] = 9, ["sept"] = 9, ["september"] = 9,
["oct"] = 10, ["october"] = 10,
["nov"] = 11, ["november"] = 11,
["dec"] = 12, ["december"] = 12,
};
public static string? TryParseYearMonth(string? raw)
{
var value = (raw ?? string.Empty).Trim();
if (value.Length == 0) return null;
if (value.Equals("present", StringComparison.OrdinalIgnoreCase) || value.Equals("current", StringComparison.OrdinalIgnoreCase)) return null;
// "2020" -> January is an assumption we don't want to make silently; year-only stays unparsed.
var monthYear = Regex.Match(value, @"^(?<month>[A-Za-z]+)\.?\s+(?<year>\d{4})$");
if (monthYear.Success && MonthNames.TryGetValue(monthYear.Groups["month"].Value, out var month))
{
return $"{monthYear.Groups["year"].Value}-{month:D2}";
}
var slash = Regex.Match(value, @"^(?<month>\d{1,2})/(?<year>\d{4})$");
if (slash.Success)
{
var m = int.Parse(slash.Groups["month"].Value);
if (m is >= 1 and <= 12) return $"{slash.Groups["year"].Value}-{m:D2}";
}
var isoLike = Regex.Match(value, @"^(?<year>\d{4})-(?<month>\d{1,2})$");
if (isoLike.Success)
{
var m = int.Parse(isoLike.Groups["month"].Value);
if (m is >= 1 and <= 12) return $"{isoLike.Groups["year"].Value}-{m:D2}";
}
return null;
}
}