feat: integrate Career Workspace foundation from feature/career-workspace
Recover the F1 Career Profile foundation + AI-workspace persistence from the unmerged feature/career-workspace branch, so Phase 2 builds on the documented, tested target state instead of re-deriving it. Foundation only — CV Builder commits (variants, ATS badge, rewrite diff) stay deferred per "do not build CV Builder yet". See docs/career-workspace-branch-assessment.md. Squashed from 3 branch commits (235e291,5916f09,00a035e), resolved against main + Phase 0: - CareerProfile + CareerProfileVersion (append-only history), dual-written from every profile save path via CareerProfileService. ApplicationUser. ProfileCvStructureJson stays authoritative; the tables mirror it. Stable item IDs assigned to jobs/education/certifications/projects (the prerequisite for future variant lineage). CvDateNormalizer for free-text -> YYYY-MM. - InterviewPrepNote + AiWorkspaceNote: cache AI interview prep / candidate fit / focus plan keyed by an attachment-context signature, so they stop regenerating (and re-spending the provider) on every open. Conflict resolutions (union, favouring current code + Phase 0): - JobTrackerContext / StartupInitializationExtensions: kept Phase 0's tables and reconciler blocks, added the career/interview/ai-note tables (both SQLite and MySQL dialects). - ProfileCvController: dropped the branch's in-file DTO records (main defines them in ProfileCvDtos.cs) and the LayoutFamily/AtsRating template fields (deferred ATS-badge work), keeping main's 7-arg CvTemplateDescriptor. - JobApplicationsController: kept the branch's cache-check, restored main's AsNoTracking on the read-only user load. Tables ship empty (verified dev); nothing to migrate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -682,6 +682,84 @@ public static class StartupInitializationExtensions
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId");""");
|
||||
}
|
||||
|
||||
// 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");""");
|
||||
}
|
||||
|
||||
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5):
|
||||
// stop re-running the AI call on every tab open by persisting the last generated
|
||||
// note per job, keyed by the attachment selection it was generated from.
|
||||
static void EnsureInterviewPrepNotesTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "InterviewPrepNotes" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepNotes" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"JobApplicationId" INTEGER NOT NULL,
|
||||
"AttachmentContextSignature" TEXT NOT NULL,
|
||||
"Summary" TEXT NOT NULL,
|
||||
"TalkingPointsJson" TEXT NOT NULL,
|
||||
"LikelyQuestionsJson" TEXT NOT NULL,
|
||||
"WeakSpotsJson" TEXT NOT NULL,
|
||||
"GeneratedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_InterviewPrepNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId" ON "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId");""");
|
||||
}
|
||||
|
||||
// Generic AI workspace note persistence (candidate fit, focus plan) -- same
|
||||
// rationale as EnsureInterviewPrepNotesTable, generalized for DTOs too irregular
|
||||
// for per-field columns.
|
||||
static void EnsureAiWorkspaceNotesTable(DbConnection c)
|
||||
{
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "AiWorkspaceNotes" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_AiWorkspaceNotes" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"JobApplicationId" INTEGER NOT NULL,
|
||||
"NoteType" TEXT NOT NULL,
|
||||
"AttachmentContextSignature" TEXT NOT NULL,
|
||||
"ResultJson" TEXT NOT NULL,
|
||||
"GeneratedAtUtc" TEXT NOT NULL,
|
||||
CONSTRAINT "FK_AiWorkspaceNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType" ON "AiWorkspaceNotes" ("OwnerUserId", "JobApplicationId", "NoteType");""");
|
||||
}
|
||||
|
||||
EnsureGmailConnectionsTable(conn);
|
||||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||||
EnsureImapConnectionsTable(conn);
|
||||
@@ -689,6 +767,9 @@ public static class StartupInitializationExtensions
|
||||
EnsureTwoFactorRecoveryCodesTable(conn);
|
||||
EnsureTrustedDevicesTable(conn);
|
||||
EnsureUserSessionsTable(conn);
|
||||
EnsureCareerProfileTables(conn);
|
||||
EnsureInterviewPrepNotesTable(conn);
|
||||
EnsureAiWorkspaceNotesTable(conn);
|
||||
|
||||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||||
// and at least one of the new columns already exists.
|
||||
@@ -825,6 +906,40 @@ 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();
|
||||
}
|
||||
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id");
|
||||
|
||||
if (!MySqlIndexExists(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_InterviewPrepNotes_OwnerUserId_JobApplicationId` ON `InterviewPrepNotes` (`OwnerUserId`, `JobApplicationId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id");
|
||||
|
||||
if (!MySqlIndexExists(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType` ON `AiWorkspaceNotes` (`OwnerUserId`, `JobApplicationId`, `NoteType`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
||||
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
||||
@@ -1053,6 +1168,93 @@ 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();
|
||||
}
|
||||
|
||||
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
|
||||
if (!HasMySqlTable(conn, "InterviewPrepNotes"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepNotes` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`JobApplicationId` int NOT NULL,
|
||||
`AttachmentContextSignature` longtext NOT NULL,
|
||||
`Summary` longtext NOT NULL,
|
||||
`TalkingPointsJson` longtext NOT NULL,
|
||||
`LikelyQuestionsJson` longtext NOT NULL,
|
||||
`WeakSpotsJson` longtext NOT NULL,
|
||||
`GeneratedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_InterviewPrepNotes_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// Generic AI workspace note persistence (candidate fit, focus plan).
|
||||
if (!HasMySqlTable(conn, "AiWorkspaceNotes"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiWorkspaceNotes` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`JobApplicationId` int NOT NULL,
|
||||
`NoteType` varchar(50) NOT NULL,
|
||||
`AttachmentContextSignature` longtext NOT NULL,
|
||||
`ResultJson` longtext NOT NULL,
|
||||
`GeneratedAtUtc` datetime(6) NOT NULL,
|
||||
PRIMARY KEY (`Id`),
|
||||
CONSTRAINT `FK_AiWorkspaceNotes_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
Reference in New Issue
Block a user