feat: add career profile foundation with versioned history

Introduces the Career Workspace's bounded data foundation, additive
and backwards-compatible: ApplicationUser.ProfileCvStructureJson
stays the authoritative column every existing read path uses; the new
CareerProfiles/CareerProfileVersions tables mirror it via
ICareerProfileService so future Career Workspace features (variants,
history UI) have real tables to build on rather than starting a
second migration later.

- CareerProfile: one snapshot row per user (Version, ProfileJson).
- CareerProfileVersion: append-only history, one row per save
  (upload/rebuild/improve/reprocess/parse), so a profile edit is never
  silently lost the way ProfileCvStructureJson overwrites are today.
- Stable item IDs assigned to jobs/education/certifications/projects
  on first save and preserved across later saves -- the prerequisite
  for CV variants to reference "this job" by identity instead of
  array position.
- CvDateNormalizer: best-effort free-string -> "YYYY-MM" parsing for
  job/education/certification/project date ranges, kept alongside
  (never replacing) the original free-string fields.
- Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects,
  matching this repo's schema-via-raw-SQL-reconciler convention
  rather than EF migrations.

Job tracking is untouched -- this is entirely within the profile/CV
domain per the Career Workspace product boundary.
This commit is contained in:
cesnimda
2026-07-12 15:18:05 +02:00
parent a4c8e4ac5d
commit 235e291d8f
8 changed files with 444 additions and 8 deletions
@@ -623,10 +623,45 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
}
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the
// authoritative column every existing read path uses; these mirror it so future
// Career Workspace features (variants, history UI) have a real table to build on.
static void EnsureCareerProfileTables(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProfiles" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ProfileJson" TEXT NOT NULL,
"Version" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"CareerProfileId" INTEGER NOT NULL,
"Version" INTEGER NOT NULL,
"ProfileJson" TEXT NOT NULL,
"Source" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId" ON "CareerProfiles" ("OwnerUserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn);
EnsureCareerProfileTables(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
@@ -752,6 +787,22 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id");
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
cmd.ExecuteNonQuery();
}
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
// Correspondences/Attachments) -- re-run once more after Migrate() below via
@@ -977,6 +1028,41 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
// Phase F1). Additive: AspNetUsers.ProfileCvStructureJson stays authoritative
// for every existing read path during the dual-write window.
if (!HasMySqlTable(conn, "CareerProfiles"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfiles` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`ProfileJson` longtext NOT NULL,
`Version` int NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CareerProfileVersions"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfileVersions` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`CareerProfileId` int NOT NULL,
`Version` int NOT NULL,
`ProfileJson` longtext NOT NULL,
`Source` varchar(100) NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerProfileVersions_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{
using var cmd = conn.CreateCommand();