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 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 LoadStructuredAsync(string ownerUserId, CancellationToken cancellationToken); // The append-only version history, newest first. Task> ListVersionsAsync(string ownerUserId, CancellationToken cancellationToken); // Restores a past version by re-saving its snapshot as a NEW version (non-destructive: history // and the current state are both preserved; the restore is itself reversible). Returns the // restored profile, or null if the version does not exist. Task RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken); // Read-only structured load for a specific owner, bypassing the tenant query filter. For public // CV rendering (/cv/{slug}) where there is no authenticated current user. Never writes, never // backfills — falls back to the ProfileJson blob if relational rows are absent. Task LoadStructuredForOwnerAsync(string ownerUserId, CancellationToken cancellationToken); } public sealed record CareerProfileVersionInfo(int Version, string Source, DateTimeOffset CreatedAtUtc, bool IsCurrent); public sealed class CareerProfileService : ICareerProfileService { private readonly JobTrackerContext _db; public CareerProfileService(JobTrackerContext db) { _db = db; } public async Task 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 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); } public async Task LoadStructuredForOwnerAsync(string ownerUserId, CancellationToken cancellationToken) { var profile = await _db.CareerProfiles.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (profile is null) return new StructuredCvProfile(); var experiences = await _db.CareerExperiences.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); var education = await _db.CareerEducations.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); var skills = await _db.CareerSkills.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); var projects = await _db.CareerProjects.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); var certifications = await _db.CareerCertifications.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); var languages = await _db.CareerLanguages.IgnoreQueryFilters().Where(x => x.CareerProfileId == profile.Id).OrderBy(x => x.SortOrder).ToListAsync(cancellationToken); // No relational rows yet (a pre-Phase-3 profile that has never been re-saved): fall back to // the blob so a public CV still renders. Read-only, so we don't backfill here. if (experiences.Count == 0 && education.Count == 0 && skills.Count == 0 && projects.Count == 0 && certifications.Count == 0 && languages.Count == 0 && !string.IsNullOrWhiteSpace(profile.ProfileJson)) { return StructuredCvProfileJson.Deserialize(profile.ProfileJson); } return CareerProfileMapper.ToStructured(profile.LongTailJson, experiences, education, skills, projects, certifications, languages); } public async Task> ListVersionsAsync(string ownerUserId, CancellationToken cancellationToken) { var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (profile is null) return Array.Empty(); var versions = await _db.CareerProfileVersions .Where(x => x.CareerProfileId == profile.Id) .OrderByDescending(x => x.Version) .Select(x => new { x.Version, x.Source, x.CreatedAtUtc }) .ToListAsync(cancellationToken); return versions.Select(x => new CareerProfileVersionInfo(x.Version, x.Source, x.CreatedAtUtc, x.Version == profile.Version)).ToList(); } public async Task RestoreVersionAsync(string ownerUserId, int version, CancellationToken cancellationToken) { var profile = await _db.CareerProfiles.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken); if (profile is null) return null; var target = await _db.CareerProfileVersions.FirstOrDefaultAsync(x => x.CareerProfileId == profile.Id && x.Version == version, cancellationToken); if (target is null) return null; // Re-save the old snapshot as a new version. Non-destructive: the current state stays in // history, so a restore can itself be undone by restoring the version before it. var restored = StructuredCvProfileJson.Deserialize(target.ProfileJson); return await SaveVersionAsync(ownerUserId, restored, $"restore:v{version}", cancellationToken); } // 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 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, @"^(?[A-Za-z]+)\.?\s+(?\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, @"^(?\d{1,2})/(?\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, @"^(?\d{4})-(?\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; } }