feat: add career profile foundation with versioned history

Introduces the Career Workspace's bounded data foundation, additive
and backwards-compatible: ApplicationUser.ProfileCvStructureJson
stays the authoritative column every existing read path uses; the new
CareerProfiles/CareerProfileVersions tables mirror it via
ICareerProfileService so future Career Workspace features (variants,
history UI) have real tables to build on rather than starting a
second migration later.

- CareerProfile: one snapshot row per user (Version, ProfileJson).
- CareerProfileVersion: append-only history, one row per save
  (upload/rebuild/improve/reprocess/parse), so a profile edit is never
  silently lost the way ProfileCvStructureJson overwrites are today.
- Stable item IDs assigned to jobs/education/certifications/projects
  on first save and preserved across later saves -- the prerequisite
  for CV variants to reference "this job" by identity instead of
  array position.
- CvDateNormalizer: best-effort free-string -> "YYYY-MM" parsing for
  job/education/certification/project date ranges, kept alongside
  (never replacing) the original free-string fields.
- Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects,
  matching this repo's schema-via-raw-SQL-reconciler convention
  rather than EF migrations.

Job tracking is untouched -- this is entirely within the profile/CV
domain per the Career Workspace product boundary.
This commit is contained in:
cesnimda
2026-07-12 15:18:05 +02:00
parent a4c8e4ac5d
commit 235e291d8f
8 changed files with 444 additions and 8 deletions
@@ -71,8 +71,9 @@ public sealed class ProfileCvController : ControllerBase
private readonly ICvPdfExporter _cvPdfExporter;
private readonly ICvProcessingQueue _cvProcessingQueue;
private readonly IAppEmailSender _emailSender;
private readonly ICareerProfileService _careerProfileService;
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null)
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null)
{
_users = users;
_aiService = aiService;
@@ -85,6 +86,7 @@ public sealed class ProfileCvController : ControllerBase
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
_emailSender = emailSender ?? NoOpEmailSender.Instance;
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
}
private sealed class NoOpEmailSender : IAppEmailSender
@@ -114,7 +116,13 @@ public sealed class ProfileCvController : ControllerBase
public string? Language { get; set; }
}
public sealed record ParseCvRequest(string? Text);
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets);
// LayoutFamily/AtsRating formalize the template catalog as data (career-workspace-implementation-roadmap.md
// Phase F3): "single-column" templates read top-to-bottom with no CSS grid split, so an ATS parser's
// extraction order matches visual order (High). "sidebar" templates use a CSS grid column split
// (competitor research flagged this pattern -- Canva's floating-box layouts -- as the #1 ATS risk
// factor), so they're rated Medium even though our structured-data rendering keeps them far safer
// than a canvas tool's undefined reading order.
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets, string LayoutFamily, string AtsRating);
public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId);
public sealed record CvRewriteFailureDto(string Code, string Message, string? Detail = null, string? LastAiError = null);
@@ -172,6 +180,7 @@ public sealed class ProfileCvController : ControllerBase
result.StructuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, result.StructuredCv, "upload", HttpContext.RequestAborted);
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
run.RawExtractedText = result.RawText;
@@ -571,12 +580,12 @@ public sealed class ProfileCvController : ControllerBase
{
return new[]
{
new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List<string> { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }),
new CvTemplateDescriptor("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new List<string> { "Classic serif rhythm", "Strong chronology", "Credible tone" }),
new CvTemplateDescriptor("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new List<string> { "Sidebar details", "Compact highlights", "Modern contrast" }),
new CvTemplateDescriptor("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new List<string> { "Premium spacing", "Stronger personality", "Readable density" }),
new CvTemplateDescriptor("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new List<string> { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }),
new CvTemplateDescriptor("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new List<string> { "Technical depth", "Dense but readable", "Practical hierarchy" }),
new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List<string> { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }, "single-column", "High"),
new CvTemplateDescriptor("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new List<string> { "Classic serif rhythm", "Strong chronology", "Credible tone" }, "single-column", "High"),
new CvTemplateDescriptor("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new List<string> { "Sidebar details", "Compact highlights", "Modern contrast" }, "sidebar", "Medium"),
new CvTemplateDescriptor("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new List<string> { "Premium spacing", "Stronger personality", "Readable density" }, "sidebar", "Medium"),
new CvTemplateDescriptor("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new List<string> { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }, "single-column", "High"),
new CvTemplateDescriptor("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new List<string> { "Technical depth", "Dense but readable", "Practical hierarchy" }, "sidebar", "Medium"),
};
}
@@ -841,6 +850,7 @@ public sealed class ProfileCvController : ControllerBase
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.StructuredProfileJson = structuredJson;
@@ -981,6 +991,7 @@ public sealed class ProfileCvController : ControllerBase
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, run.Trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.RawExtractedText = rawText;