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:
@@ -28,6 +28,8 @@ namespace JobTrackerApi.Data
|
|||||||
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
|
public DbSet<CvUploadArtifact> CvUploadArtifacts => Set<CvUploadArtifact>();
|
||||||
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
|
public DbSet<CvExtractionRun> CvExtractionRuns => Set<CvExtractionRun>();
|
||||||
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
public DbSet<TailoredCvDraft> TailoredCvDrafts => Set<TailoredCvDraft>();
|
||||||
|
public DbSet<CareerProfile> CareerProfiles => Set<CareerProfile>();
|
||||||
|
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -141,6 +143,27 @@ namespace JobTrackerApi.Data
|
|||||||
.WithOne(j => j.TailoredCvDraft)
|
.WithOne(j => j.TailoredCvDraft)
|
||||||
.HasForeignKey<TailoredCvDraft>(x => x.JobApplicationId)
|
.HasForeignKey<TailoredCvDraft>(x => x.JobApplicationId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md Phase F1).
|
||||||
|
// One CareerProfile per user for now -- see roadmap "Not now: multiple profiles per user".
|
||||||
|
modelBuilder.Entity<CareerProfile>()
|
||||||
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CareerProfile>()
|
||||||
|
.HasIndex(x => x.OwnerUserId)
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<CareerProfileVersion>()
|
||||||
|
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CareerProfileVersion>()
|
||||||
|
.HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.Version });
|
||||||
|
|
||||||
|
modelBuilder.Entity<CareerProfileVersion>()
|
||||||
|
.HasOne(x => x.CareerProfile)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.CareerProfileId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class CareerProfileServiceTests
|
||||||
|
{
|
||||||
|
private static JobTrackerContext NewContext(string userId)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||||
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
|
.Options;
|
||||||
|
var currentUser = new Mock<ICurrentUserService>();
|
||||||
|
currentUser.SetupGet(service => service.UserId).Returns(userId);
|
||||||
|
return new JobTrackerContext(options, currentUser.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveVersionAsync_assigns_stable_ids_to_items_missing_one()
|
||||||
|
{
|
||||||
|
await using var db = NewContext("user-1");
|
||||||
|
var service = new CareerProfileService(db);
|
||||||
|
var profile = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var saved = await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(saved.Jobs[0].Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveVersionAsync_preserves_existing_ids_across_saves()
|
||||||
|
{
|
||||||
|
await using var db = NewContext("user-1");
|
||||||
|
var service = new CareerProfileService(db);
|
||||||
|
var profile = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Jobs = { new StructuredCvJob { Title = "Engineer", Company = "Acme" } },
|
||||||
|
};
|
||||||
|
await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||||
|
var firstId = profile.Jobs[0].Id;
|
||||||
|
|
||||||
|
await service.SaveVersionAsync("user-1", profile, "rebuild", default);
|
||||||
|
|
||||||
|
Assert.Equal(firstId, profile.Jobs[0].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("January 2020", "2020-01")]
|
||||||
|
[InlineData("Mar 2019", "2019-03")]
|
||||||
|
[InlineData("03/2019", "2019-03")]
|
||||||
|
[InlineData("2019-03", "2019-03")]
|
||||||
|
[InlineData("Present", null)]
|
||||||
|
[InlineData("2020", null)]
|
||||||
|
[InlineData(null, null)]
|
||||||
|
public void CvDateNormalizer_parses_common_formats_without_guessing(string? input, string? expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, CvDateNormalizer.TryParseYearMonth(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveVersionAsync_persists_current_snapshot_and_append_only_history()
|
||||||
|
{
|
||||||
|
await using var db = NewContext("user-1");
|
||||||
|
var service = new CareerProfileService(db);
|
||||||
|
var profile = new StructuredCvProfile { Summary = { "First version" } };
|
||||||
|
|
||||||
|
await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||||
|
await service.SaveVersionAsync("user-1", profile, "improve", default);
|
||||||
|
|
||||||
|
var snapshots = await db.CareerProfiles.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
|
||||||
|
var history = await db.CareerProfileVersions.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1").ToListAsync();
|
||||||
|
|
||||||
|
Assert.Single(snapshots);
|
||||||
|
Assert.Equal(2, snapshots[0].Version);
|
||||||
|
Assert.Equal(2, history.Count);
|
||||||
|
Assert.Equal(new[] { "upload", "improve" }, history.OrderBy(x => x.Version).Select(x => x.Source));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveVersionAsync_does_not_normalize_end_date_when_job_is_current()
|
||||||
|
{
|
||||||
|
await using var db = NewContext("user-1");
|
||||||
|
var service = new CareerProfileService(db);
|
||||||
|
var profile = new StructuredCvProfile
|
||||||
|
{
|
||||||
|
Jobs = { new StructuredCvJob { Title = "Engineer", Start = "Jan 2020", End = "Present", IsCurrent = true } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var saved = await service.SaveVersionAsync("user-1", profile, "upload", default);
|
||||||
|
|
||||||
|
Assert.Equal("2020-01", saved.Jobs[0].StartDate);
|
||||||
|
Assert.Null(saved.Jobs[0].EndDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,8 +71,9 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
private readonly ICvPdfExporter _cvPdfExporter;
|
private readonly ICvPdfExporter _cvPdfExporter;
|
||||||
private readonly ICvProcessingQueue _cvProcessingQueue;
|
private readonly ICvProcessingQueue _cvProcessingQueue;
|
||||||
private readonly IAppEmailSender _emailSender;
|
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;
|
_users = users;
|
||||||
_aiService = aiService;
|
_aiService = aiService;
|
||||||
@@ -85,6 +86,7 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||||
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
|
_cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance;
|
||||||
_emailSender = emailSender ?? NoOpEmailSender.Instance;
|
_emailSender = emailSender ?? NoOpEmailSender.Instance;
|
||||||
|
_careerProfileService = careerProfileService ?? new CareerProfileService(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class NoOpEmailSender : IAppEmailSender
|
private sealed class NoOpEmailSender : IAppEmailSender
|
||||||
@@ -114,7 +116,13 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
public string? Language { get; set; }
|
public string? Language { get; set; }
|
||||||
}
|
}
|
||||||
public sealed record ParseCvRequest(string? Text);
|
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 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);
|
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.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||||
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
result.StructuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||||
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
result.StructuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
await _careerProfileService.SaveVersionAsync(user.Id, result.StructuredCv, "upload", HttpContext.RequestAborted);
|
||||||
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
|
var structuredJson = StructuredCvProfileJson.Serialize(result.StructuredCv);
|
||||||
|
|
||||||
run.RawExtractedText = result.RawText;
|
run.RawExtractedText = result.RawText;
|
||||||
@@ -571,12 +580,12 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
{
|
{
|
||||||
return new[]
|
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("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" }),
|
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" }),
|
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" }),
|
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" }),
|
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" }),
|
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.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||||
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||||
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken);
|
||||||
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
|
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
|
||||||
run.StructuredProfileJson = structuredJson;
|
run.StructuredProfileJson = structuredJson;
|
||||||
|
|
||||||
@@ -981,6 +991,7 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
|
||||||
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
|
||||||
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||||
|
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, run.Trigger, cancellationToken);
|
||||||
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
|
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
|
||||||
|
|
||||||
run.RawExtractedText = rawText;
|
run.RawExtractedText = rawText;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ builder.Services.AddSingleton<ICvProcessingQueue, CvProcessingQueue>();
|
|||||||
builder.Services.AddTransient<ProfileCvController>();
|
builder.Services.AddTransient<ProfileCvController>();
|
||||||
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
|
||||||
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
|
||||||
|
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<AppPaths>();
|
builder.Services.AddSingleton<AppPaths>();
|
||||||
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
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, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -623,10 +623,45 @@ public static class StartupInitializationExtensions
|
|||||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
|
||||||
|
// Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the
|
||||||
|
// authoritative column every existing read path uses; these mirror it so future
|
||||||
|
// Career Workspace features (variants, history UI) have a real table to build on.
|
||||||
|
static void EnsureCareerProfileTables(DbConnection c)
|
||||||
|
{
|
||||||
|
Exec(c, """
|
||||||
|
CREATE TABLE IF NOT EXISTS "CareerProfiles" (
|
||||||
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"OwnerUserId" TEXT NOT NULL,
|
||||||
|
"ProfileJson" TEXT NOT NULL,
|
||||||
|
"Version" INTEGER NOT NULL,
|
||||||
|
"CreatedAtUtc" TEXT NOT NULL,
|
||||||
|
"UpdatedAtUtc" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
Exec(c, """
|
||||||
|
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
|
||||||
|
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"OwnerUserId" TEXT NOT NULL,
|
||||||
|
"CareerProfileId" INTEGER NOT NULL,
|
||||||
|
"Version" INTEGER NOT NULL,
|
||||||
|
"ProfileJson" TEXT NOT NULL,
|
||||||
|
"Source" TEXT NOT NULL,
|
||||||
|
"CreatedAtUtc" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""");
|
||||||
|
|
||||||
|
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId" ON "CareerProfiles" ("OwnerUserId");""");
|
||||||
|
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
|
||||||
|
}
|
||||||
|
|
||||||
EnsureGmailConnectionsTable(conn);
|
EnsureGmailConnectionsTable(conn);
|
||||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||||
EnsureImapConnectionsTable(conn);
|
EnsureImapConnectionsTable(conn);
|
||||||
EnsureCvTables(conn);
|
EnsureCvTables(conn);
|
||||||
|
EnsureCareerProfileTables(conn);
|
||||||
|
|
||||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||||
// and at least one of the new columns already exists.
|
// and at least one of the new columns already exists.
|
||||||
@@ -752,6 +787,22 @@ public static class StartupInitializationExtensions
|
|||||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
|
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
|
||||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
|
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
|
||||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
|
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
|
||||||
|
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id");
|
||||||
|
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id");
|
||||||
|
|
||||||
|
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
||||||
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
||||||
@@ -977,6 +1028,41 @@ public static class StartupInitializationExtensions
|
|||||||
cmd.ExecuteNonQuery();
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
|
||||||
|
// Phase F1). Additive: AspNetUsers.ProfileCvStructureJson stays authoritative
|
||||||
|
// for every existing read path during the dual-write window.
|
||||||
|
if (!HasMySqlTable(conn, "CareerProfiles"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfiles` (
|
||||||
|
`Id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`OwnerUserId` varchar(255) NOT NULL,
|
||||||
|
`ProfileJson` longtext NOT NULL,
|
||||||
|
`Version` int NOT NULL,
|
||||||
|
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||||
|
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||||||
|
PRIMARY KEY (`Id`)
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!HasMySqlTable(conn, "CareerProfileVersions"))
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfileVersions` (
|
||||||
|
`Id` int NOT NULL AUTO_INCREMENT,
|
||||||
|
`OwnerUserId` varchar(255) NOT NULL,
|
||||||
|
`CareerProfileId` int NOT NULL,
|
||||||
|
`Version` int NOT NULL,
|
||||||
|
`ProfileJson` longtext NOT NULL,
|
||||||
|
`Source` varchar(100) NOT NULL,
|
||||||
|
`CreatedAtUtc` datetime(6) NOT NULL,
|
||||||
|
PRIMARY KEY (`Id`),
|
||||||
|
CONSTRAINT `FK_CareerProfileVersions_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
using var cmd = conn.CreateCommand();
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace JobTrackerApi.Models;
|
||||||
|
|
||||||
|
// The Career Workspace's durable source of truth. Bounded to one row per user for now
|
||||||
|
// (see career-workspace-implementation-roadmap.md Phase F1) -- ProfileJson mirrors
|
||||||
|
// ApplicationUser.ProfileCvStructureJson during the dual-write window and will become
|
||||||
|
// authoritative once every read path is migrated (F5).
|
||||||
|
public sealed class CareerProfile
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string OwnerUserId { get; set; } = string.Empty;
|
||||||
|
public string ProfileJson { get; set; } = string.Empty;
|
||||||
|
public int Version { get; set; }
|
||||||
|
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append-only history: one row per save, so profile edits are never silently lost.
|
||||||
|
public sealed class CareerProfileVersion
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string OwnerUserId { get; set; } = string.Empty;
|
||||||
|
public int CareerProfileId { get; set; }
|
||||||
|
public CareerProfile? CareerProfile { get; set; }
|
||||||
|
public int Version { get; set; }
|
||||||
|
public string ProfileJson { get; set; } = string.Empty;
|
||||||
|
// Where this version came from: "upload" | "rebuild" | "improve" | "reprocess" | "parse" | "manual".
|
||||||
|
public string Source { get; set; } = string.Empty;
|
||||||
|
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -49,11 +49,19 @@ public sealed class StructuredCvContact
|
|||||||
|
|
||||||
public sealed class StructuredCvJob
|
public sealed class StructuredCvJob
|
||||||
{
|
{
|
||||||
|
// Stable item ID (assigned by CareerProfileService on first save). Required for CV variants
|
||||||
|
// to reference "this job" across profile edits, instead of by array position. Nullable/empty
|
||||||
|
// on freshly-parsed or legacy data until the first save assigns it.
|
||||||
|
public string? Id { get; set; }
|
||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
public string? Company { get; set; }
|
public string? Company { get; set; }
|
||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
public string? Start { get; set; }
|
public string? Start { get; set; }
|
||||||
public string? End { get; set; }
|
public string? End { get; set; }
|
||||||
|
// Best-effort "YYYY-MM" normalization of Start/End, computed alongside Id assignment.
|
||||||
|
// Null when Start/End can't be parsed; the free-string fields above remain the display source.
|
||||||
|
public string? StartDate { get; set; }
|
||||||
|
public string? EndDate { get; set; }
|
||||||
public bool IsCurrent { get; set; }
|
public bool IsCurrent { get; set; }
|
||||||
public List<string> Bullets { get; set; } = new();
|
public List<string> Bullets { get; set; } = new();
|
||||||
public List<string> Skills { get; set; } = new();
|
public List<string> Skills { get; set; } = new();
|
||||||
@@ -61,31 +69,39 @@ public sealed class StructuredCvJob
|
|||||||
|
|
||||||
public sealed class StructuredCvEducation
|
public sealed class StructuredCvEducation
|
||||||
{
|
{
|
||||||
|
public string? Id { get; set; }
|
||||||
public string? Qualification { get; set; }
|
public string? Qualification { get; set; }
|
||||||
public string? QualificationLevel { get; set; }
|
public string? QualificationLevel { get; set; }
|
||||||
public string? Institution { get; set; }
|
public string? Institution { get; set; }
|
||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
public string? Start { get; set; }
|
public string? Start { get; set; }
|
||||||
public string? End { get; set; }
|
public string? End { get; set; }
|
||||||
|
public string? StartDate { get; set; }
|
||||||
|
public string? EndDate { get; set; }
|
||||||
public List<string> Details { get; set; } = new();
|
public List<string> Details { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StructuredCvCertification
|
public sealed class StructuredCvCertification
|
||||||
{
|
{
|
||||||
|
public string? Id { get; set; }
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public string? Issuer { get; set; }
|
public string? Issuer { get; set; }
|
||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
public string? Date { get; set; }
|
public string? Date { get; set; }
|
||||||
|
public string? DateNormalized { get; set; }
|
||||||
public List<string> Details { get; set; } = new();
|
public List<string> Details { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class StructuredCvProject
|
public sealed class StructuredCvProject
|
||||||
{
|
{
|
||||||
|
public string? Id { get; set; }
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public string? Role { get; set; }
|
public string? Role { get; set; }
|
||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
public string? Start { get; set; }
|
public string? Start { get; set; }
|
||||||
public string? End { get; set; }
|
public string? End { get; set; }
|
||||||
|
public string? StartDate { get; set; }
|
||||||
|
public string? EndDate { get; set; }
|
||||||
public List<string> Bullets { get; set; } = new();
|
public List<string> Bullets { get; set; } = new();
|
||||||
public List<string> Skills { get; set; } = new();
|
public List<string> Skills { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user