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.
This commit is contained in:
cesnimda
2026-07-12 15:55:44 +02:00
parent 00c7e0b6ca
commit 47d05ba946
5 changed files with 388 additions and 0 deletions
+49
View File
@@ -32,6 +32,9 @@ namespace JobTrackerApi.Data
public DbSet<CareerProfileVersion> CareerProfileVersions => Set<CareerProfileVersion>();
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
public DbSet<CvVersion> CvVersions => Set<CvVersion>();
public DbSet<TailoredApplication> TailoredApplications => Set<TailoredApplication>();
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<CvVariant>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CvVariant>()
.HasIndex(x => x.OwnerUserId);
modelBuilder.Entity<CvVariant>()
.HasOne(x => x.CareerProfile)
.WithMany()
.HasForeignKey(x => x.CareerProfileId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<CvVersion>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<CvVersion>()
.HasIndex(x => new { x.OwnerUserId, x.CvVariantId, x.Version });
modelBuilder.Entity<CvVersion>()
.HasOne(x => x.CvVariant)
.WithMany()
.HasForeignKey(x => x.CvVariantId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TailoredApplication>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<TailoredApplication>()
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId })
.IsUnique();
modelBuilder.Entity<TailoredApplication>()
.HasOne(x => x.CvVariant)
.WithMany()
.HasForeignKey(x => x.CvVariantId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<TailoredApplication>()
.HasOne(x => x.JobApplication)
.WithMany()
.HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}
+105
View File
@@ -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<string> { "Built things." }, new List<string> { "C#" },
new List<TailoredCvExperienceItem>(), new List<TailoredCvEducationItem>(), new List<TailoredCvCustomSection>(), null, "edited"), CancellationToken.None);
Assert.IsType<NoContentResult>(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<string> { "Built things." }, new List<string> { "C#" },
new List<TailoredCvExperienceItem>(), new List<TailoredCvEducationItem>(), new List<TailoredCvCustomSection>(), 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<JobApplication> 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<ISummarizerService>(),
Mock.Of<IAppEmailSender>(),
TestHostFactory.CreateUserManager(user).Object,
NullLogger<JobApplicationsController>.Instance,
Mock.Of<ICvTemplateRenderer>(),
Mock.Of<ICvPdfExporter>());
controller.ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId)
}, "test"))
}
};
return controller;
}
}
@@ -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<List<string>> 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();
}
@@ -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();
+52
View File
@@ -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;
}