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);
}
}
}