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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user