diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 9f699e4..178091a 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -28,6 +28,8 @@ namespace JobTrackerApi.Data public DbSet CvUploadArtifacts => Set(); public DbSet CvExtractionRuns => Set(); public DbSet TailoredCvDrafts => Set(); + public DbSet CareerProfiles => Set(); + public DbSet CareerProfileVersions => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -141,6 +143,27 @@ namespace JobTrackerApi.Data .WithOne(j => j.TailoredCvDraft) .HasForeignKey(x => x.JobApplicationId) .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() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => x.OwnerUserId) + .IsUnique(); + + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.CareerProfileId, x.Version }); + + modelBuilder.Entity() + .HasOne(x => x.CareerProfile) + .WithMany() + .HasForeignKey(x => x.CareerProfileId) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/JobTrackerApi.Tests/CareerProfileServiceTests.cs b/JobTrackerApi.Tests/CareerProfileServiceTests.cs new file mode 100644 index 0000000..e018fec --- /dev/null +++ b/JobTrackerApi.Tests/CareerProfileServiceTests.cs @@ -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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var currentUser = new Mock(); + 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); + } +} diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index a599d69..d3fe762 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -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 users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null) + public ProfileCvController(UserManager users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger? 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 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 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 { "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 { "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 { "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 { "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 { "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 { "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 { "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 { "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 { "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 { "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 { "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 { "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; diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 5f73116..ec0e31d 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -37,6 +37,7 @@ builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/CareerProfileService.cs b/JobTrackerApi/Services/CareerProfileService.cs new file mode 100644 index 0000000..46ce17e --- /dev/null +++ b/JobTrackerApi/Services/CareerProfileService.cs @@ -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 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 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 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; + } +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index c3cf867..458faec 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -623,10 +623,45 @@ public static class StartupInitializationExtensions 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); EnsureMicrosoftGraphConnectionsTable(conn); EnsureImapConnectionsTable(conn); EnsureCvTables(conn); + EnsureCareerProfileTables(conn); // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // and at least one of the new columns already exists. @@ -752,6 +787,22 @@ public static class StartupInitializationExtensions EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "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/ // Correspondences/Attachments) -- re-run once more after Migrate() below via @@ -977,6 +1028,41 @@ public static class StartupInitializationExtensions 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")) { using var cmd = conn.CreateCommand(); diff --git a/Models/CareerProfile.cs b/Models/CareerProfile.cs new file mode 100644 index 0000000..826c045 --- /dev/null +++ b/Models/CareerProfile.cs @@ -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; +} diff --git a/Models/StructuredCvProfile.cs b/Models/StructuredCvProfile.cs index 6a4b628..53bcbce 100644 --- a/Models/StructuredCvProfile.cs +++ b/Models/StructuredCvProfile.cs @@ -49,11 +49,19 @@ public sealed class StructuredCvContact 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? Company { get; set; } public string? Location { get; set; } public string? Start { 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 List Bullets { get; set; } = new(); public List Skills { get; set; } = new(); @@ -61,31 +69,39 @@ public sealed class StructuredCvJob public sealed class StructuredCvEducation { + public string? Id { get; set; } public string? Qualification { get; set; } public string? QualificationLevel { get; set; } public string? Institution { get; set; } public string? Location { get; set; } public string? Start { get; set; } public string? End { get; set; } + public string? StartDate { get; set; } + public string? EndDate { get; set; } public List Details { get; set; } = new(); } public sealed class StructuredCvCertification { + public string? Id { get; set; } public string? Name { get; set; } public string? Issuer { get; set; } public string? Location { get; set; } public string? Date { get; set; } + public string? DateNormalized { get; set; } public List Details { get; set; } = new(); } public sealed class StructuredCvProject { + public string? Id { get; set; } public string? Name { get; set; } public string? Role { get; set; } public string? Location { get; set; } public string? Start { get; set; } public string? End { get; set; } + public string? StartDate { get; set; } + public string? EndDate { get; set; } public List Bullets { get; set; } = new(); public List Skills { get; set; } = new(); }