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
@@ -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();