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