From 47d05ba946544a5b260f2d9cd0b4e471c470331a Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 12 Jul 2026 15:55:44 +0200 Subject: [PATCH] feat: introduce CV variant schema, dual-written from tailored CV saves Phase F2 of the Career Workspace roadmap: CvVariant, CvVersion, and TailoredApplication -- the reference seam. Per the product boundary, a job application REFERENCES a tailored output; it does not own it. CvVariant is not job-owned: it survives job deletion (SetNull on its optional CareerProfile link, not cascaded), can be reused across applications, and carries its own append-only CvVersion history. TailoredApplication is the join that links a variant to a job (cascades with either side, since the link is meaningless without both). Rather than shipping empty tables with no consumer, this dual-writes from both existing TailoredCvDraft save paths (SaveTailoredCvDraft, UpsertGeneratedTailoredCvDraftAsync via GenerateTailoredCvDraft) -- same pattern as CareerProfile in Phase F1. TailoredCvDraft remains authoritative for every existing read path; the sync is additive and never blocks or fails a draft save. 2 new tests: variant/version/link created on first save, same variant reused (not duplicated) with version incrementing on subsequent saves. Verified against the real dev DB -- FK dependency ordering (CareerProfiles -> CvVariants -> CvVersions/TailoredApplications) holds in both SQLite and MySQL reconciler dialects. --- Data/JobTrackerContext.cs | 49 +++++++ JobTrackerApi.Tests/CvVariantSyncTests.cs | 105 ++++++++++++++ .../Controllers/JobApplicationsController.cs | 54 ++++++++ .../StartupInitializationExtensions.cs | 128 ++++++++++++++++++ Models/CvVariant.cs | 52 +++++++ 5 files changed, 388 insertions(+) create mode 100644 JobTrackerApi.Tests/CvVariantSyncTests.cs create mode 100644 Models/CvVariant.cs diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index 8358d09..ea220bd 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -32,6 +32,9 @@ namespace JobTrackerApi.Data public DbSet CareerProfileVersions => Set(); public DbSet InterviewPrepNotes => Set(); public DbSet AiWorkspaceNotes => Set(); + public DbSet CvVariants => Set(); + public DbSet CvVersions => Set(); + public DbSet TailoredApplications => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -196,6 +199,52 @@ namespace JobTrackerApi.Data .WithMany() .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + + // CV variants (career-workspace-implementation-roadmap.md Phase F2). A variant is not + // owned by a job -- it survives job deletion and can be reused across applications; only + // TailoredApplication (the reference) is job-scoped. + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => x.OwnerUserId); + + modelBuilder.Entity() + .HasOne(x => x.CareerProfile) + .WithMany() + .HasForeignKey(x => x.CareerProfileId) + .OnDelete(DeleteBehavior.SetNull); + + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.CvVariantId, x.Version }); + + modelBuilder.Entity() + .HasOne(x => x.CvVariant) + .WithMany() + .HasForeignKey(x => x.CvVariantId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(x => x.CvVariant) + .WithMany() + .HasForeignKey(x => x.CvVariantId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(x => x.JobApplication) + .WithMany() + .HasForeignKey(x => x.JobApplicationId) + .OnDelete(DeleteBehavior.Cascade); } } } diff --git a/JobTrackerApi.Tests/CvVariantSyncTests.cs b/JobTrackerApi.Tests/CvVariantSyncTests.cs new file mode 100644 index 0000000..75fbf30 --- /dev/null +++ b/JobTrackerApi.Tests/CvVariantSyncTests.cs @@ -0,0 +1,105 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +// Career Workspace foundation, Phase F2 (career-workspace-implementation-roadmap.md). Every +// TailoredCvDraft save should dual-write a CvVariant + CvVersion + TailoredApplication without +// changing TailoredCvDraft's own behavior -- these tests lock in that seam. +public sealed class CvVariantSyncTests +{ + [Fact] + public async Task SaveTailoredCvDraft_creates_a_variant_version_and_application_link() + { + await using var db = TestHostFactory.CreateInMemoryDb(); + var job = await SeedJobAsync(db); + var controller = CreateController(db, "user-1"); + + var result = await controller.SaveTailoredCvDraft(job.Id, new JobApplicationsController.SaveTailoredCvDraftRequest( + "ats-minimal", "Backend Engineer", new List { "Built things." }, new List { "C#" }, + new List(), new List(), new List(), null, "edited"), CancellationToken.None); + + Assert.IsType(result); + + var variant = Assert.Single(db.CvVariants.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1")); + Assert.Equal(1, variant.Version); + Assert.Contains("Backend Developer", variant.Name); + + var link = Assert.Single(db.TailoredApplications.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id)); + Assert.Equal(variant.Id, link.CvVariantId); + + var version = Assert.Single(db.CvVersions.IgnoreQueryFilters().Where(x => x.CvVariantId == variant.Id)); + Assert.Equal(1, version.Version); + } + + [Fact] + public async Task SaveTailoredCvDraft_reuses_the_same_variant_on_subsequent_saves() + { + await using var db = TestHostFactory.CreateInMemoryDb(); + var job = await SeedJobAsync(db); + var controller = CreateController(db, "user-1"); + + var request = new JobApplicationsController.SaveTailoredCvDraftRequest( + "ats-minimal", "Backend Engineer", new List { "Built things." }, new List { "C#" }, + new List(), new List(), new List(), null, "edited"); + + await controller.SaveTailoredCvDraft(job.Id, request, CancellationToken.None); + await controller.SaveTailoredCvDraft(job.Id, request with { Headline = "Updated headline" }, CancellationToken.None); + + var variant = Assert.Single(db.CvVariants.IgnoreQueryFilters().Where(x => x.OwnerUserId == "user-1")); + Assert.Equal(2, variant.Version); + + var links = db.TailoredApplications.IgnoreQueryFilters().Where(x => x.JobApplicationId == job.Id).ToList(); + Assert.Single(links); + + var versions = db.CvVersions.IgnoreQueryFilters().Where(x => x.CvVariantId == variant.Id).ToList(); + Assert.Equal(2, versions.Count); + } + + private static async Task SeedJobAsync(JobTrackerContext db) + { + var company = new Company { Name = "Acme", OwnerUserId = "user-1" }; + db.Companies.Add(company); + db.Users.Add(new ApplicationUser { Id = "user-1", UserName = "user@example.test", Email = "user@example.test" }); + await db.SaveChangesAsync(); + + var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + return job; + } + + private static JobApplicationsController CreateController(JobTrackerContext db, string userId) + { + var user = db.Users.AsNoTracking().FirstOrDefault(x => x.Id == userId); + var controller = new JobApplicationsController( + db, + Mock.Of(), + Mock.Of(), + TestHostFactory.CreateUserManager(user).Object, + NullLogger.Instance, + Mock.Of(), + Mock.Of()); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "test")) + } + }; + return controller; + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index c36fe19..3271940 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -436,9 +436,62 @@ Canonical profile: job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document); job.TailoredCvUpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(cancellationToken); + await SyncCvVariantFromDraftAsync(user.Id, job, document, cancellationToken); return draft; } + // Career Workspace foundation, Phase F2 (career-workspace-implementation-roadmap.md). + // Dual-writes a CvVariant + CvVersion + TailoredApplication whenever a TailoredCvDraft is + // saved, so the new tables are populated from real usage without needing new UI yet. + // TailoredCvDraft remains authoritative; this never blocks or fails draft saves. + private async Task SyncCvVariantFromDraftAsync(string ownerUserId, JobApplication job, TailoredCvDocument document, CancellationToken cancellationToken) + { + var existingLink = await _db.TailoredApplications + .Include(x => x.CvVariant) + .FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == job.Id, cancellationToken); + + var variant = existingLink?.CvVariant; + if (variant is null) + { + var companyName = job.Company?.Name ?? (await _db.Companies.FirstOrDefaultAsync(c => c.Id == job.CompanyId, cancellationToken))?.Name; + var careerProfileId = await _db.CareerProfiles.Where(x => x.OwnerUserId == ownerUserId).Select(x => (int?)x.Id).FirstOrDefaultAsync(cancellationToken); + variant = new CvVariant + { + OwnerUserId = ownerUserId, + CareerProfileId = careerProfileId, + Name = string.Join(" @ ", new[] { job.JobTitle, companyName }.Where(x => !string.IsNullOrWhiteSpace(x))), + }; + _db.CvVariants.Add(variant); + await _db.SaveChangesAsync(cancellationToken); + } + + variant.ContentJson = JsonSerializer.Serialize(document); + variant.ThemeId = string.IsNullOrWhiteSpace(document.TemplateId) ? "ats-minimal" : document.TemplateId; + variant.Version += 1; + variant.UpdatedAtUtc = DateTimeOffset.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + + _db.CvVersions.Add(new CvVersion + { + OwnerUserId = ownerUserId, + CvVariantId = variant.Id, + Version = variant.Version, + ContentJson = variant.ContentJson, + }); + + if (existingLink is null) + { + _db.TailoredApplications.Add(new TailoredApplication + { + OwnerUserId = ownerUserId, + CvVariantId = variant.Id, + JobApplicationId = job.Id, + }); + } + + await _db.SaveChangesAsync(cancellationToken); + } + private async Task> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix) { var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70); @@ -2696,6 +2749,7 @@ Candidate master CV: job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document); job.TailoredCvUpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(cancellationToken); + await SyncCvVariantFromDraftAsync(user.Id, job, document, cancellationToken); return NoContent(); } diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 4b6a936..598dea5 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -701,6 +701,54 @@ public static class StartupInitializationExtensions Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType" ON "AiWorkspaceNotes" ("OwnerUserId", "JobApplicationId", "NoteType");"""); } + // CV variants (career-workspace-implementation-roadmap.md Phase F2). Dual-written + // from the existing TailoredCvDraft save paths; TailoredCvDraft stays authoritative. + static void EnsureCvVariantTables(DbConnection c) + { + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CvVariants" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CvVariants" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "CareerProfileId" INTEGER NULL, + "Name" TEXT NOT NULL, + "ContentJson" TEXT NOT NULL, + "ThemeId" TEXT NOT NULL, + "Version" INTEGER NOT NULL, + "CreatedAtUtc" TEXT NOT NULL, + "UpdatedAtUtc" TEXT NOT NULL, + CONSTRAINT "FK_CvVariants_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE SET NULL + ); + """); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "CvVersions" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_CvVersions" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "CvVariantId" INTEGER NOT NULL, + "Version" INTEGER NOT NULL, + "ContentJson" TEXT NOT NULL, + "CreatedAtUtc" TEXT NOT NULL, + CONSTRAINT "FK_CvVersions_CvVariants_CvVariantId" FOREIGN KEY ("CvVariantId") REFERENCES "CvVariants" ("Id") ON DELETE CASCADE + ); + """); + + Exec(c, """ + CREATE TABLE IF NOT EXISTS "TailoredApplications" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredApplications" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, + "CvVariantId" INTEGER NOT NULL, + "JobApplicationId" INTEGER NOT NULL, + "CreatedAtUtc" TEXT NOT NULL, + CONSTRAINT "FK_TailoredApplications_CvVariants_CvVariantId" FOREIGN KEY ("CvVariantId") REFERENCES "CvVariants" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_TailoredApplications_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE + ); + """); + + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariants_OwnerUserId" ON "CvVariants" ("OwnerUserId");"""); + Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVersions_OwnerUserId_CvVariantId_Version" ON "CvVersions" ("OwnerUserId", "CvVariantId", "Version");"""); + Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredApplications_OwnerUserId_JobApplicationId" ON "TailoredApplications" ("OwnerUserId", "JobApplicationId");"""); + } + EnsureGmailConnectionsTable(conn); EnsureMicrosoftGraphConnectionsTable(conn); EnsureImapConnectionsTable(conn); @@ -708,6 +756,7 @@ public static class StartupInitializationExtensions EnsureCareerProfileTables(conn); EnsureInterviewPrepNotesTable(conn); EnsureAiWorkspaceNotesTable(conn); + EnsureCvVariantTables(conn); // Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded, // and at least one of the new columns already exists. @@ -868,6 +917,31 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVersions", "Id"); + EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredApplications", "Id"); + + if (!MySqlIndexExists(conn, "CvVariants", "IX_CvVariants_OwnerUserId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_CvVariants_OwnerUserId` ON `CvVariants` (`OwnerUserId`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "CvVersions", "IX_CvVersions_OwnerUserId_CvVariantId_Version")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE INDEX `IX_CvVersions_OwnerUserId_CvVariantId_Version` ON `CvVersions` (`OwnerUserId`, `CvVariantId`, `Version`);"; + cmd.ExecuteNonQuery(); + } + + if (!MySqlIndexExists(conn, "TailoredApplications", "IX_TailoredApplications_OwnerUserId_JobApplicationId")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredApplications_OwnerUserId_JobApplicationId` ON `TailoredApplications` (`OwnerUserId`, `JobApplicationId`);"; + cmd.ExecuteNonQuery(); + } + // Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/ // Correspondences/Attachments) -- re-run once more after Migrate() below via // ReconcileCoreAppColumnsMySql, in case this is a brand-new database. @@ -1165,6 +1239,60 @@ public static class StartupInitializationExtensions cmd.ExecuteNonQuery(); } + // CV variants (career-workspace-implementation-roadmap.md Phase F2). Order + // matters: CvVariants before CvVersions/TailoredApplications (FK dependency), + // and CareerProfiles (created above) before CvVariants. + if (!HasMySqlTable(conn, "CvVariants")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariants` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `CareerProfileId` int NULL, + `Name` longtext NOT NULL, + `ContentJson` longtext NOT NULL, + `ThemeId` varchar(100) NOT NULL, + `Version` int NOT NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + `UpdatedAtUtc` datetime(6) NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CvVariants_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE SET NULL + );"; + cmd.ExecuteNonQuery(); + } + + if (!HasMySqlTable(conn, "CvVersions")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVersions` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `CvVariantId` int NOT NULL, + `Version` int NOT NULL, + `ContentJson` longtext NOT NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_CvVersions_CvVariants_CvVariantId` FOREIGN KEY (`CvVariantId`) REFERENCES `CvVariants` (`Id`) ON DELETE CASCADE + );"; + cmd.ExecuteNonQuery(); + } + + if (!HasMySqlTable(conn, "TailoredApplications")) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredApplications` ( + `Id` int NOT NULL AUTO_INCREMENT, + `OwnerUserId` varchar(255) NOT NULL, + `CvVariantId` int NOT NULL, + `JobApplicationId` int NOT NULL, + `CreatedAtUtc` datetime(6) NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_TailoredApplications_CvVariants_CvVariantId` FOREIGN KEY (`CvVariantId`) REFERENCES `CvVariants` (`Id`) ON DELETE CASCADE, + CONSTRAINT `FK_TailoredApplications_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE + );"; + cmd.ExecuteNonQuery(); + } + if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId")) { using var cmd = conn.CreateCommand(); diff --git a/Models/CvVariant.cs b/Models/CvVariant.cs new file mode 100644 index 0000000..fd0627f --- /dev/null +++ b/Models/CvVariant.cs @@ -0,0 +1,52 @@ +namespace JobTrackerApi.Models; + +// Career Workspace foundation, Phase F2 (career-workspace-implementation-roadmap.md). A CvVariant +// is a durable, named lens on the career profile -- unlike TailoredCvDraft, it is not owned by a +// job; TailoredApplication is the reference that links a variant to a job, so the same variant can +// be reused across applications and a job can be deleted without losing the variant. +// +// This phase dual-writes from the existing TailoredCvDraft save paths (same pattern as +// CareerProfile in Phase F1): every draft save also upserts the job's CvVariant, appends a +// CvVersion snapshot, and ensures a TailoredApplication link. TailoredCvDraft remains the +// authoritative row every existing read path uses. +public sealed class CvVariant +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int? CareerProfileId { get; set; } + public CareerProfile? CareerProfile { get; set; } + public string Name { get; set; } = string.Empty; + // Serialized TailoredCvDocument -- reuses the existing document shape rather than inventing a + // new one, so this phase carries zero data-shape risk. + public string ContentJson { get; set; } = string.Empty; + public string ThemeId { get; set; } = "ats-minimal"; + 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 CvVariant save. +public sealed class CvVersion +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int CvVariantId { get; set; } + public CvVariant? CvVariant { get; set; } + public int Version { get; set; } + public string ContentJson { get; set; } = string.Empty; + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} + +// The integration seam per the product boundary: a job application REFERENCES a tailored output, +// it does not own it. Deleting a job does not delete the variant; the variant can outlive the job +// or be reused by a future one. +public sealed class TailoredApplication +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int CvVariantId { get; set; } + public CvVariant? CvVariant { get; set; } + public int JobApplicationId { get; set; } + public JobApplication? JobApplication { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +}