Files
jobtrackingapp/JobTrackerApi/Services/StartupInitializationExtensions.cs
T
cesnimda de937d25dc
CI and Deploy / test (pull_request) Successful in 4m40s
CI and Deploy / deploy (pull_request) Has been skipped
CI and Deploy / test (push) Successful in 4m11s
CI and Deploy / deploy (push) Successful in 1m36s
fix(db): repair Jobs identity
Production's SQLite-shaped Jobs table lacks AUTO_INCREMENT, causing the legacy opportunity backfill to abort startup. Reuse the existing idempotent MariaDB primary-key repair before backfill runs.
2026-08-02 19:22:23 +02:00

2197 lines
129 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Data.Common;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace JobTrackerApi.Services;
public static class StartupInitializationExtensions
{
// SQLite-dialect schema helpers. Promoted from local functions to class-level statics so a
// second reconciliation pass can run after Migrate() creates the base tables on a brand-new
// database (see the CoreSchemaReady-adjacent block near the end of InitializeJobTrackerAsync):
// the ad-hoc EnsureColumn calls below no-op on a table that doesn't exist yet, so a genuinely
// fresh boot needs them re-run once Migrate() has created JobApplications/Correspondences.
private static bool HasTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$name";
p.Value = table;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
private static bool HasColumn(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
return cmd.ExecuteScalar() is not null;
}
private static bool HasMigration(DbConnection c, string migrationId)
{
if (!HasTable(c, "__EFMigrationsHistory")) return false;
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$id";
p.Value = migrationId;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
private static void Exec(DbConnection c, string sql)
{
using var cmd = c.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
private static void EnsureColumn(DbConnection c, string table, string column, string ddl)
{
// Fresh databases won't have the table until EF migrations run.
if (!HasTable(c, table)) return;
if (!HasColumn(c, table, column)) Exec(c, ddl);
}
// Ad-hoc columns/backfills added over time without a matching EF migration (the reason the
// ModelSnapshot drifted -- see the SyncModelSnapshot migration's doc comment). Safe to call
// any number of times against any connection state: every check no-ops if the table or
// column doesn't exist yet or already matches.
private static void ReconcileCoreAppColumns(DbConnection conn)
{
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Provider", "ALTER TABLE Correspondences ADD COLUMN Provider TEXT NULL;");
if (HasTable(conn, "Correspondences"))
{
Exec(conn, "UPDATE Correspondences SET Provider = 'gmail' WHERE Provider IS NULL AND ExternalThreadId IS NOT NULL;");
Exec(conn, "UPDATE Correspondences SET Provider = 'manual' WHERE Provider IS NULL;");
}
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
}
// MySQL/MariaDB-dialect equivalents of the helpers above.
private static bool HasMySqlTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
return cmd.ExecuteScalar() is not null;
}
private static bool HasMySqlIndex(DbConnection c, string table, string indexName)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND INDEX_NAME = @index LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
var p3 = cmd.CreateParameter(); p3.ParameterName = "@index"; p3.Value = indexName; cmd.Parameters.Add(p3);
return cmd.ExecuteScalar() is not null;
}
// The one place an index is created on MySQL. Guarded on TABLE existence, not just index
// existence: the reconciler runs a pass BEFORE Migrate(), so on a brand-new database the
// migration-owned tables do not exist yet. Repairing an absent table is not this pass's job --
// it is skipped, Migrate() creates it, and the post-Migrate pass then finds it and reconciles.
// Without this guard a clean MariaDB install died on "Table 'CareerProfiles' doesn't exist".
// See docs/infrastructure/database-ownership.md.
private static void EnsureMySqlIndex(DbConnection c, string table, string indexName, string columnsSql, bool unique = false)
{
if (!HasMySqlTable(c, table)) return;
if (HasMySqlIndex(c, table, indexName)) return;
using var cmd = c.CreateCommand();
cmd.CommandText = $"CREATE {(unique ? "UNIQUE " : string.Empty)}INDEX `{indexName}` ON `{table}` ({columnsSql});";
cmd.ExecuteNonQuery();
}
// A migration scaffolded against SQLite bakes SQLite type names into its DDL ("TEXT" for
// DateTimeOffset, "INTEGER" for bool/int, no AUTO_INCREMENT). Run against MariaDB it produces a
// structurally wrong table -- and a composite index over a TEXT column then blows MySQL's
// 3072-byte key limit, which is what crashed backend startup on prod. Detect that shape by
// probing one column's data type and rebuild the table from the correct MySQL DDL.
//
// Guarded on row count: an empty malformed table is dropped and recreated; a table with ANY rows
// is left untouched (never destroy user data -- surface it instead of silently deleting).
private static bool DropMalformedMySqlTable(DbConnection c, string table, string probeColumn, string expectedDataType)
{
if (!HasMySqlTable(c, table)) return false;
string? actual;
using (var probe = c.CreateCommand())
{
probe.CommandText = "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
var s = probe.CreateParameter(); s.ParameterName = "@schema"; s.Value = c.Database; probe.Parameters.Add(s);
var t = probe.CreateParameter(); t.ParameterName = "@table"; t.Value = table; probe.Parameters.Add(t);
var col = probe.CreateParameter(); col.ParameterName = "@column"; col.Value = probeColumn; probe.Parameters.Add(col);
actual = probe.ExecuteScalar() as string;
}
// Unknown column or already the right type: nothing to repair.
if (string.IsNullOrEmpty(actual) || string.Equals(actual, expectedDataType, StringComparison.OrdinalIgnoreCase)) return false;
long rows;
using (var count = c.CreateCommand())
{
count.CommandText = $"SELECT COUNT(*) FROM `{table}`;";
rows = Convert.ToInt64(count.ExecuteScalar() ?? 0L);
}
if (rows > 0) return false;
using var drop = c.CreateCommand();
drop.CommandText = $"DROP TABLE `{table}`;";
drop.ExecuteNonQuery();
return true;
}
private static bool MySqlColumnExists(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
return cmd.ExecuteScalar() is not null;
}
private static void EnsureMySqlColumn(DbConnection c, string table, string column, string ddl)
{
if (!HasMySqlTable(c, table)) return;
if (MySqlColumnExists(c, table, column)) return;
using var ddlCmd = c.CreateCommand();
ddlCmd.CommandText = ddl;
ddlCmd.ExecuteNonQuery();
}
// MySQL mirror of ReconcileCoreAppColumns -- same rationale (re-run after Migrate() on a
// brand-new database, where these tables didn't exist yet during the pre-Migrate pass).
private static void ReconcileCoreAppColumnsMySql(DbConnection conn)
{
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;");
EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;");
EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Provider", "ALTER TABLE `Correspondences` ADD COLUMN `Provider` varchar(50) NULL;");
if (HasMySqlTable(conn, "Correspondences"))
{
using (var backfillGmail = conn.CreateCommand())
{
backfillGmail.CommandText = "UPDATE `Correspondences` SET `Provider` = 'gmail' WHERE `Provider` IS NULL AND `ExternalThreadId` IS NOT NULL;";
backfillGmail.ExecuteNonQuery();
}
using (var backfillManual = conn.CreateCommand())
{
backfillManual.CommandText = "UPDATE `Correspondences` SET `Provider` = 'manual' WHERE `Provider` IS NULL;";
backfillManual.ExecuteNonQuery();
}
}
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
}
public static Task InitializeJobTrackerAsync(this WebApplication app)
{
// Apply EF migrations on startup (SQLite dev DB lives in the repo).
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var paths = scope.ServiceProvider.GetRequiredService<AppPaths>();
var users = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var roles = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var useSqliteBootstrap = provider is not "mysql" and not "mariadb";
static void EnsureIdentityTablesMySql(DbConnection c)
{
using var cmd = c.CreateCommand();
cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS `AspNetRoles` (
`Id` varchar(255) NOT NULL,
`Name` varchar(256) NULL,
`NormalizedName` varchar(256) NULL,
`ConcurrencyStamp` longtext NULL,
PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `AspNetUsers` (
`Id` varchar(255) NOT NULL,
`UserName` varchar(256) NULL,
`NormalizedUserName` varchar(256) NULL,
`Email` varchar(256) NULL,
`NormalizedEmail` varchar(256) NULL,
`EmailConfirmed` tinyint(1) NOT NULL,
`PasswordHash` longtext NULL,
`SecurityStamp` longtext NULL,
`ConcurrencyStamp` longtext NULL,
`PhoneNumber` longtext NULL,
`PhoneNumberConfirmed` tinyint(1) NOT NULL,
`TwoFactorEnabled` tinyint(1) NOT NULL,
`LockoutEnd` datetime(6) NULL,
`LockoutEnabled` tinyint(1) NOT NULL,
`AccessFailedCount` int NOT NULL,
`FirstName` longtext NULL,
`LastName` longtext NULL,
`DisplayName` longtext NULL,
`ProfileCvText` longtext NULL,
`ProfileCvStructureJson` longtext NULL,
`CurrentCvUploadArtifactId` int NULL,
`CurrentCvExtractionRunId` int NULL,
`CurrentCvProfileVersion` int NULL,
`AvatarImageDataUrl` longtext NULL,
`GoogleSubject` longtext NULL,
`GoogleEmail` longtext NULL,
`GoogleLinkedAt` datetime(6) NULL,
`MicrosoftSubject` longtext NULL,
`MicrosoftEmail` longtext NULL,
`MicrosoftLinkedAt` datetime(6) NULL,
`TotpSecretEncrypted` longtext NULL,
`TotpPendingSecretEncrypted` longtext NULL,
`TotpEnabledAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `AspNetRoleClaims` (
`Id` int NOT NULL AUTO_INCREMENT,
`RoleId` varchar(255) NOT NULL,
`ClaimType` longtext NULL,
`ClaimValue` longtext NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_AspNetRoleClaims_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `AspNetUserClaims` (
`Id` int NOT NULL AUTO_INCREMENT,
`UserId` varchar(255) NOT NULL,
`ClaimType` longtext NULL,
`ClaimValue` longtext NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_AspNetUserClaims_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `AspNetUserLogins` (
`LoginProvider` varchar(255) NOT NULL,
`ProviderKey` varchar(255) NOT NULL,
`ProviderDisplayName` longtext NULL,
`UserId` varchar(255) NOT NULL,
PRIMARY KEY (`LoginProvider`, `ProviderKey`),
CONSTRAINT `FK_AspNetUserLogins_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `AspNetUserRoles` (
`UserId` varchar(255) NOT NULL,
`RoleId` varchar(255) NOT NULL,
PRIMARY KEY (`UserId`, `RoleId`),
CONSTRAINT `FK_AspNetUserRoles_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE,
CONSTRAINT `FK_AspNetUserRoles_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE TABLE IF NOT EXISTS `AspNetUserTokens` (
`UserId` varchar(255) NOT NULL,
`LoginProvider` varchar(255) NOT NULL,
`Name` varchar(255) NOT NULL,
`Value` longtext NULL,
PRIMARY KEY (`UserId`, `LoginProvider`, `Name`),
CONSTRAINT `FK_AspNetUserTokens_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
CREATE UNIQUE INDEX IF NOT EXISTS `RoleNameIndex` ON `AspNetRoles` (`NormalizedName`);
CREATE INDEX IF NOT EXISTS `IX_AspNetRoleClaims_RoleId` ON `AspNetRoleClaims` (`RoleId`);
CREATE INDEX IF NOT EXISTS `EmailIndex` ON `AspNetUsers` (`NormalizedEmail`);
CREATE UNIQUE INDEX IF NOT EXISTS `UserNameIndex` ON `AspNetUsers` (`NormalizedUserName`);
CREATE INDEX IF NOT EXISTS `IX_AspNetUserClaims_UserId` ON `AspNetUserClaims` (`UserId`);
CREATE INDEX IF NOT EXISTS `IX_AspNetUserLogins_UserId` ON `AspNetUserLogins` (`UserId`);
CREATE INDEX IF NOT EXISTS `IX_AspNetUserRoles_RoleId` ON `AspNetUserRoles` (`RoleId`);
";
cmd.ExecuteNonQuery();
}
// The schema reconciler. Runs TWICE: once before Migrate() and once after.
//
// Before: legacy databases need their Identity tables and hand-added columns present, and
// the legacy migration-history stamp written, or Migrate() collides with them.
// After: on a brand-new database the migration-owned tables did not exist during the first
// pass, so everything that depends on them (reconciler-owned tables that FK into
// JobApplications, index and AUTO_INCREMENT repairs) was skipped. The second pass finds them
// and finishes the job.
//
// Every statement in here is existence-guarded, so running it twice is a no-op scan on an
// already-correct database. docs/infrastructure/database-ownership.md.
void ReconcileSchema()
{
if (useSqliteBootstrap)
{
// Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied).
// If the schema already contains the columns added by migration 20260310195000, record that migration
// so EF doesn't try to apply it again and fail on duplicate columns.
const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete";
const string legacyProductVersion = "7.0.17";
// Not a `using`: the connection belongs to the DbContext, and ReconcileSchema runs more
// than once — disposing it here made the second pass throw ObjectDisposedException.
DbConnection conn = db.Database.GetDbConnection();
// ReconcileSchema runs twice; the second pass may inherit an already-open connection.
if (conn.State != System.Data.ConnectionState.Open) conn.Open();
static void EnsureIdentityTables(DbConnection c)
{
// EF migrations are used for the app schema. In some environments `dotnet ef` isnt available,
// so create the ASP.NET Core Identity tables directly if they dont exist yet.
if (HasTable(c, "AspNetUsers")) return;
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetRoles" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY,
"Name" TEXT NULL,
"NormalizedName" TEXT NULL,
"ConcurrencyStamp" TEXT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUsers" (
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY,
"UserName" TEXT NULL,
"NormalizedUserName" TEXT NULL,
"Email" TEXT NULL,
"NormalizedEmail" TEXT NULL,
"EmailConfirmed" INTEGER NOT NULL,
"PasswordHash" TEXT NULL,
"SecurityStamp" TEXT NULL,
"ConcurrencyStamp" TEXT NULL,
"PhoneNumber" TEXT NULL,
"PhoneNumberConfirmed" INTEGER NOT NULL,
"TwoFactorEnabled" INTEGER NOT NULL,
"LockoutEnd" TEXT NULL,
"LockoutEnabled" INTEGER NOT NULL,
"AccessFailedCount" INTEGER NOT NULL,
"FirstName" TEXT NULL,
"LastName" TEXT NULL,
"DisplayName" TEXT NULL,
"ProfileCvText" TEXT NULL,
"ProfileCvStructureJson" TEXT NULL,
"CurrentCvUploadArtifactId" INTEGER NULL,
"CurrentCvExtractionRunId" INTEGER NULL,
"CurrentCvProfileVersion" INTEGER NULL,
"AvatarImageDataUrl" TEXT NULL,
"GoogleSubject" TEXT NULL,
"GoogleEmail" TEXT NULL,
"GoogleLinkedAt" TEXT NULL,
"MicrosoftSubject" TEXT NULL,
"MicrosoftEmail" TEXT NULL,
"MicrosoftLinkedAt" TEXT NULL,
"TotpSecretEncrypted" TEXT NULL,
"TotpPendingSecretEncrypted" TEXT NULL,
"TotpEnabledAtUtc" TEXT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT,
"RoleId" TEXT NOT NULL,
"ClaimType" TEXT NULL,
"ClaimValue" TEXT NULL,
CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserClaims" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"ClaimType" TEXT NULL,
"ClaimValue" TEXT NULL,
CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserLogins" (
"LoginProvider" TEXT NOT NULL,
"ProviderKey" TEXT NOT NULL,
"ProviderDisplayName" TEXT NULL,
"UserId" TEXT NOT NULL,
CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"),
CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserRoles" (
"UserId" TEXT NOT NULL,
"RoleId" TEXT NOT NULL,
CONSTRAINT "PK_AspNetUserRoles" PRIMARY KEY ("UserId", "RoleId"),
CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
"UserId" TEXT NOT NULL,
"LoginProvider" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"Value" TEXT NULL,
CONSTRAINT "PK_AspNetUserTokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"),
CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");""");
}
EnsureIdentityTables(conn);
EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvStructureJson TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvUploadArtifactId INTEGER NULL;");
EnsureColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvExtractionRunId INTEGER NULL;");
EnsureColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvProfileVersion INTEGER NULL;");
EnsureColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE AspNetUsers ADD COLUMN AvatarImageDataUrl TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftLinkedAt TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpSecretEncrypted TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpPendingSecretEncrypted TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE AspNetUsers ADD COLUMN TotpEnabledAtUtc TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeCustomerId", "ALTER TABLE AspNetUsers ADD COLUMN StripeCustomerId TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionId TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionStatus TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
static void EnsureUserRuleSettingsTable(DbConnection c)
{
if (HasTable(c, "UserRuleSettings")) return;
Exec(c, """
CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
"OwnerUserId" TEXT NOT NULL CONSTRAINT "PK_UserRuleSettings" PRIMARY KEY,
"AppliedFollowUpDays" INTEGER NOT NULL,
"AppliedGhostDays" INTEGER NOT NULL,
"OfferFollowUpDays" INTEGER NOT NULL,
"OfferGhostDays" INTEGER NOT NULL,
"FeedbackFollowUpDays" INTEGER NOT NULL,
"FeedbackGhostDays" INTEGER NOT NULL
);
""");
}
EnsureUserRuleSettingsTable(conn);
static void EnsureGmailConnectionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "GmailConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"GmailAddress" TEXT NOT NULL,
"EncryptedRefreshToken" TEXT NOT NULL,
"EncryptedAccessToken" TEXT NULL,
"AccessTokenExpiresAt" TEXT NULL,
"Scope" TEXT NOT NULL,
"ConnectedAt" TEXT NOT NULL,
"LastSyncedAt" TEXT NULL,
"LastSyncAttemptedAt" TEXT NULL,
"LastSyncSucceededAt" TEXT NULL,
"LastSyncMode" TEXT NULL,
"LastSyncSource" TEXT NULL,
"LastSyncStatus" TEXT NULL,
"LastSyncError" TEXT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "GmailReviewDecisions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailReviewDecisions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ThreadId" TEXT NOT NULL,
"JobApplicationId" INTEGER NULL,
"Decision" TEXT NOT NULL,
"Note" TEXT NULL,
"UpdatedAt" TEXT NOT NULL
);
""");
EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSucceededAt TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncMode", "ALTER TABLE GmailConnections ADD COLUMN LastSyncMode TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncSource", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSource TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncStatus", "ALTER TABLE GmailConnections ADD COLUMN LastSyncStatus TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncError", "ALTER TABLE GmailConnections ADD COLUMN LastSyncError TEXT NULL;");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
}
static void EnsureMicrosoftGraphConnectionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "MicrosoftGraphConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_MicrosoftGraphConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"MailAddress" TEXT NOT NULL,
"EncryptedRefreshToken" TEXT NOT NULL,
"EncryptedAccessToken" TEXT NULL,
"AccessTokenExpiresAt" TEXT NULL,
"Scope" TEXT NOT NULL,
"ConnectedAt" TEXT NOT NULL,
"LastSyncedAt" TEXT NULL,
"LastSyncAttemptedAt" TEXT NULL,
"LastSyncSucceededAt" TEXT NULL,
"LastSyncMode" TEXT NULL,
"LastSyncSource" TEXT NULL,
"LastSyncStatus" TEXT NULL,
"LastSyncError" TEXT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId" ON "MicrosoftGraphConnections" ("OwnerUserId");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress" ON "MicrosoftGraphConnections" ("OwnerUserId", "MailAddress");""");
}
static void EnsureImapConnectionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "ImapConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_ImapConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"Host" TEXT NOT NULL,
"Port" INTEGER NOT NULL,
"UseSsl" INTEGER NOT NULL,
"Username" TEXT NOT NULL,
"EncryptedPassword" TEXT NOT NULL,
"ConnectedAt" TEXT NOT NULL,
"LastSyncedAt" TEXT NULL,
"LastSyncAttemptedAt" TEXT NULL,
"LastSyncSucceededAt" TEXT NULL,
"LastSyncMode" TEXT NULL,
"LastSyncSource" TEXT NULL,
"LastSyncStatus" TEXT NULL,
"LastSyncError" TEXT NULL
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_ImapConnections_OwnerUserId" ON "ImapConnections" ("OwnerUserId");""");
}
static void EnsureCvTables(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "CvUploadArtifacts" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvUploadArtifacts" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"OriginalFileName" TEXT NOT NULL,
"StoredFileName" TEXT NOT NULL,
"MimeType" TEXT NOT NULL,
"ByteSize" INTEGER NOT NULL,
"Sha256" TEXT NOT NULL,
"StoragePath" TEXT NOT NULL,
"UploadedAtUtc" TEXT NOT NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CvExtractionRuns" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvExtractionRuns" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ArtifactId" INTEGER NULL,
"Trigger" TEXT NOT NULL,
"ParserVersion" TEXT NOT NULL,
"NormalizerVersion" TEXT NOT NULL,
"LlmPromptVersion" TEXT NOT NULL,
"Status" TEXT NOT NULL,
"RawExtractedText" TEXT NULL,
"NormalizedText" TEXT NULL,
"StructuredProfileJson" TEXT NULL,
"ErrorMessage" TEXT NULL,
"StartedAtUtc" TEXT NOT NULL,
"CompletedAtUtc" TEXT NULL,
"AppliedAtUtc" TEXT NULL,
CONSTRAINT "FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId" FOREIGN KEY ("ArtifactId") REFERENCES "CvUploadArtifacts" ("Id") ON DELETE SET NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc" ON "CvUploadArtifacts" ("OwnerUserId", "UploadedAtUtc");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"CanonicalProfileVersion" INTEGER NULL,
"TemplateId" TEXT NOT NULL,
"Headline" TEXT NULL,
"SummaryJson" TEXT NULL,
"SelectedSkillsJson" TEXT NULL,
"ExperienceJson" TEXT NULL,
"EducationJson" TEXT NULL,
"CustomSectionsJson" TEXT NULL,
"RenderOptionsJson" TEXT NULL,
"GenerationContextHash" TEXT NULL,
"LastGeneratedAtUtc" TEXT NULL,
"LastEditedAtUtc" TEXT NULL,
"Status" TEXT NOT NULL,
CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId" ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
}
static void EnsureTwoFactorRecoveryCodesTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "TwoFactorRecoveryCodes" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"CodeHash" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UsedAtUtc" TEXT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc" ON "TwoFactorRecoveryCodes" ("UserId", "UsedAtUtc");""");
}
static void EnsureTrustedDevicesTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "TrustedDevices" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT,
"UserId" TEXT NOT NULL,
"TokenHash" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_UserId" ON "TrustedDevices" ("UserId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_TokenHash" ON "TrustedDevices" ("TokenHash");""");
}
static void EnsureUserSessionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "UserSessions" (
"Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY,
"UserId" TEXT NOT NULL,
"DeviceLabel" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"LastSeenAtUtc" TEXT NOT NULL,
"ExpiresAtUtc" TEXT NOT NULL,
"RevokedAtUtc" TEXT NULL
);
""");
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,
"LongTailJson" TEXT NOT NULL DEFAULT '',
"Version" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL
);
""");
// LongTailJson was added to the CareerProfile model (Phase 3) but this CREATE and
// the MySQL one were never updated to match, and no column-repair existed — so a
// CareerProfiles table created before this line lacks the column and /api/cv/outline
// (CareerProfileService.LoadStructuredAsync) fails with "Unknown column LongTailJson".
// Additive repair for existing databases.
EnsureColumn(c, "CareerProfiles", "LongTailJson", """ALTER TABLE "CareerProfiles" ADD COLUMN "LongTailJson" TEXT NOT NULL DEFAULT '';""");
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");""");
// The six relational children of CareerProfile. Reconciler-owned: their migration
// is a no-op because it was scaffolded against SQLite and produced an unindexable
// longtext OwnerUserId on MariaDB. docs/infrastructure/database-ownership.md.
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerExperiences" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerExperiences" PRIMARY KEY AUTOINCREMENT,
"Title" TEXT NULL,
"Company" TEXT NULL,
"Location" TEXT NULL,
"Start" TEXT NULL,
"End" TEXT NULL,
"StartDate" TEXT NULL,
"EndDate" TEXT NULL,
"IsCurrent" INTEGER NOT NULL DEFAULT 0,
"BulletsJson" TEXT NOT NULL DEFAULT '[]',
"SkillsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerExperiences_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_CareerProfileId" ON "CareerExperiences" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder" ON "CareerExperiences" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerEducations" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerEducations" PRIMARY KEY AUTOINCREMENT,
"Qualification" TEXT NULL,
"QualificationLevel" TEXT NULL,
"Institution" TEXT NULL,
"Location" TEXT NULL,
"Start" TEXT NULL,
"End" TEXT NULL,
"StartDate" TEXT NULL,
"EndDate" TEXT NULL,
"DetailsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerEducations_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerEducations_CareerProfileId" ON "CareerEducations" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder" ON "CareerEducations" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerSkills" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerSkills" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Category" TEXT NULL,
"Proficiency" TEXT NULL,
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerSkills_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerSkills_CareerProfileId" ON "CareerSkills" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder" ON "CareerSkills" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerProjects" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProjects" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Role" TEXT NULL,
"Location" TEXT NULL,
"Start" TEXT NULL,
"End" TEXT NULL,
"StartDate" TEXT NULL,
"EndDate" TEXT NULL,
"BulletsJson" TEXT NOT NULL DEFAULT '[]',
"SkillsJson" TEXT NOT NULL DEFAULT '[]',
"LinksJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerProjects_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProjects_CareerProfileId" ON "CareerProjects" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder" ON "CareerProjects" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerCertifications" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerCertifications" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Issuer" TEXT NULL,
"Location" TEXT NULL,
"Date" TEXT NULL,
"DateNormalized" TEXT NULL,
"DetailsJson" TEXT NOT NULL DEFAULT '[]',
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerCertifications_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_CareerProfileId" ON "CareerCertifications" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder" ON "CareerCertifications" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CareerLanguages" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerLanguages" PRIMARY KEY AUTOINCREMENT,
"Name" TEXT NULL,
"Level" TEXT NULL,
"Notes" TEXT NULL,
"CareerProfileId" INTEGER NOT NULL,
"OwnerUserId" TEXT NOT NULL,
"ItemKey" TEXT NOT NULL,
"SortOrder" INTEGER NOT NULL,
CONSTRAINT "FK_CareerLanguages_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_CareerProfileId" ON "CareerLanguages" ("CareerProfileId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder" ON "CareerLanguages" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
}
// 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");""");
}
// Phase 4 CV Builder: a variant is a lens over the master profile; versions are its
// autosave history. Reconciler-owned (not migration-owned) so the schema is MySQL-safe
// on prod -- see the AddCvVariants migration note. docs/architecture/cv-builder.md.
static void EnsureCvBuilderTables(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "CvVariants" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvVariants" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"PublicSlug" TEXT NOT NULL,
"Name" TEXT NOT NULL,
"JobApplicationId" INTEGER NULL,
"SettingsJson" TEXT NOT NULL,
"IsPublic" INTEGER NOT NULL,
"Version" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_CvVariants_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE SET NULL
);
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "CvVariantVersions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvVariantVersions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"CvVariantId" INTEGER NOT NULL,
"Version" INTEGER NOT NULL,
"SettingsJson" TEXT NOT NULL,
"Source" TEXT NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_CvVariantVersions_CvVariants_CvVariantId" FOREIGN KEY ("CvVariantId") REFERENCES "CvVariants" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariants_JobApplicationId" ON "CvVariants" ("JobApplicationId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariants_OwnerUserId_UpdatedAtUtc" ON "CvVariants" ("OwnerUserId", "UpdatedAtUtc");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CvVariants_PublicSlug" ON "CvVariants" ("PublicSlug");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariantVersions_CvVariantId_Version" ON "CvVariantVersions" ("CvVariantId", "Version");""");
}
// Phase 5 AI Workspace: append-only AI interaction history per job application.
static void EnsureAiInteractionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "AiInteractions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_AiInteractions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"Module" TEXT NOT NULL,
"Mode" TEXT NULL,
"Title" TEXT NOT NULL,
"Provider" TEXT NOT NULL,
"ResultJson" TEXT NOT NULL,
"InputCharacterCount" INTEGER NOT NULL DEFAULT 0,
"OutputCharacterCount" INTEGER NOT NULL DEFAULT 0,
"EstimatedTokenCount" INTEGER NOT NULL DEFAULT 0,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_AiInteractions_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
EnsureColumn(c, "AiInteractions", "InputCharacterCount", "ALTER TABLE AiInteractions ADD COLUMN InputCharacterCount INTEGER NOT NULL DEFAULT 0;");
EnsureColumn(c, "AiInteractions", "OutputCharacterCount", "ALTER TABLE AiInteractions ADD COLUMN OutputCharacterCount INTEGER NOT NULL DEFAULT 0;");
EnsureColumn(c, "AiInteractions", "EstimatedTokenCount", "ALTER TABLE AiInteractions ADD COLUMN EstimatedTokenCount INTEGER NOT NULL DEFAULT 0;");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AiInteractions_JobApplicationId" ON "AiInteractions" ("JobApplicationId");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AiInteractions_Owner_Job_Module_Created" ON "AiInteractions" ("OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc");""");
}
// Phase 5 Milestone 2: the application checklist (workflow guidance over readiness signals).
static void EnsureApplicationChecklistTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "ApplicationChecklistItems" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_ApplicationChecklistItems" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"SystemKey" TEXT NULL,
"AutoSignal" TEXT NULL,
"Title" TEXT NOT NULL,
"Description" TEXT NULL,
"Category" TEXT NOT NULL,
"Status" TEXT NOT NULL,
"Section" TEXT NULL,
"SortOrder" INTEGER NOT NULL,
"IsSystemGenerated" INTEGER NOT NULL,
"IsAutoCompleted" INTEGER NOT NULL,
"CompletedAt" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_ApplicationChecklistItems_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_ApplicationChecklistItems_JobApplicationId_SystemKey" ON "ApplicationChecklistItems" ("JobApplicationId", "SystemKey");""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_ApplicationChecklistItems_Owner_Job_Sort" ON "ApplicationChecklistItems" ("OwnerUserId", "JobApplicationId", "SortOrder");""");
}
// Phase 5.4: append-only cover letter history.
static void EnsureCoverLetterVersionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "CoverLetterVersions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CoverLetterVersions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"Version" INTEGER NOT NULL,
"Text" TEXT NOT NULL,
"Source" TEXT NOT NULL,
"AiAction" TEXT NULL,
"CreatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_CoverLetterVersions_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CoverLetterVersions_Owner_Job_Version" ON "CoverLetterVersions" ("OwnerUserId", "JobApplicationId", "Version");""");
}
// Phase 5.5: user-owned interview preparation (not the AI cache InterviewPrepNote).
static void EnsureInterviewPrepItemsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "InterviewPrepItems" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepItems" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"JobApplicationId" INTEGER NOT NULL,
"Category" TEXT NOT NULL,
"Title" TEXT NOT NULL,
"Content" TEXT NULL,
"Source" TEXT NOT NULL,
"IsPrepared" INTEGER NOT NULL,
"SortOrder" INTEGER NOT NULL,
"CreatedAtUtc" TEXT NOT NULL,
"UpdatedAtUtc" TEXT NOT NULL,
CONSTRAINT "FK_InterviewPrepItems_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_InterviewPrepItems_Owner_Job_Sort" ON "InterviewPrepItems" ("OwnerUserId", "JobApplicationId", "SortOrder");""");
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn);
EnsureTwoFactorRecoveryCodesTable(conn);
EnsureTrustedDevicesTable(conn);
EnsureUserSessionsTable(conn);
EnsureCareerProfileTables(conn);
EnsureInterviewPrepNotesTable(conn);
EnsureAiWorkspaceNotesTable(conn);
EnsureCvBuilderTables(conn);
EnsureAiInteractionsTable(conn);
EnsureApplicationChecklistTable(conn);
EnsureCoverLetterVersionsTable(conn);
EnsureInterviewPrepItemsTable(conn);
// Once the base app tables exist, provision this reconciler-owned schema set and
// stamp its historical migration before later migrations rebuild JobApplications.
var isLegacy =
HasMigration(conn, "20260310174114_AddCorrespondence") &&
!HasMigration(conn, legacyMigrationId);
if (isLegacy)
{
EnsureColumn(conn, "Companies", "Source", "ALTER TABLE Companies ADD COLUMN Source TEXT NULL;");
EnsureColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE JobApplications ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;");
EnsureColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE JobApplications ADD COLUMN DeletedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "Location", "ALTER TABLE JobApplications ADD COLUMN Location TEXT NULL;");
EnsureColumn(conn, "JobApplications", "Salary", "ALTER TABLE JobApplications ADD COLUMN Salary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "NextAction", "ALTER TABLE JobApplications ADD COLUMN NextAction TEXT NULL;");
EnsureColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE JobApplications ADD COLUMN FollowUpAt TEXT NULL;");
// Ensure the persisted short summary column exists for older dev DBs.
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
// Multi-user support: scope data to the authenticated user.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
// Legacy DBs may be missing later correspondence columns (Subject/Channel).
if (HasTable(conn, "Correspondences"))
{
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
}
// Record the migration as applied.
Exec(
conn,
"INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) " +
$"VALUES ('{legacyMigrationId}', '{legacyProductVersion}');"
);
}
// Some dev DBs may not match the "legacy" fingerprint above but still lack
// later ad-hoc columns. Ensure them unconditionally if missing (also re-run once
// more after Migrate() below, in case this is a brand-new DB where these tables
// didn't exist yet at this point).
ReconcileCoreAppColumns(conn);
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). Guarded
// on table existence: on a brand-new DB the table is created by Migrate()
// below, so the index is picked up on the next start.
if (HasTable(conn, "JobApplications"))
{
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted" ON "JobApplications" ("OwnerUserId", "IsDeleted");""");
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_FollowUpAt" ON "JobApplications" ("OwnerUserId", "FollowUpAt");""");
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted_Status" ON "JobApplications" ("OwnerUserId", "IsDeleted", "Status");""");
}
if (HasTable(conn, "Correspondences"))
{
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_Correspondences_JobApplicationId" ON "Correspondences" ("JobApplicationId");""");
}
if (HasTable(conn, "JobEvents"))
{
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobEvents_JobApplicationId" ON "JobEvents" ("JobApplicationId");""");
}
// Ensure data folder exists before creating/opening SQLite files.
Directory.CreateDirectory(paths.DataRoot);
}
else
{
var cs = app.Configuration.GetConnectionString("JobTracker");
if (!string.IsNullOrWhiteSpace(cs))
{
// Not a `using`: the connection belongs to the DbContext, and ReconcileSchema runs more
// than once — disposing it here made the second pass throw ObjectDisposedException.
DbConnection conn = db.Database.GetDbConnection();
// ReconcileSchema runs twice; the second pass may inherit an already-open connection.
if (conn.State != System.Data.ConnectionState.Open) conn.Open();
EnsureIdentityTablesMySql(conn);
static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = @"SELECT EXTRA
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column
LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
var extra = cmd.ExecuteScalar()?.ToString() ?? string.Empty;
return extra.Contains("auto_increment", StringComparison.OrdinalIgnoreCase);
}
static void EnsureMySqlAutoIncrementPrimaryKey(DbConnection c, string table, string column)
{
if (!HasMySqlTable(c, table) || !MySqlColumnExists(c, table, column) || MySqlIntPrimaryKeyIsAutoIncrement(c, table, column))
{
return;
}
using var cmd = c.CreateCommand();
cmd.CommandText = $"ALTER TABLE `{table}` MODIFY COLUMN `{column}` int NOT NULL AUTO_INCREMENT;";
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "Companies", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "Jobs", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "JobApplications", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "Correspondences", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "Attachments", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "ImapConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id");
EnsureMySqlIndex(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId", "`OwnerUserId`", unique: true);
EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`");
// The six relational children of CareerProfile — reconciler-owned (their
// migration is a no-op). varchar OwnerUserId/ItemKey so the composite index fits
// inside MariaDB's key limit. docs/infrastructure/database-ownership.md.
if (!HasMySqlTable(conn, "CareerExperiences") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerExperiences = conn.CreateCommand();
cmdCareerExperiences.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerExperiences` (
`Id` int NOT NULL AUTO_INCREMENT,
`Title` longtext NULL,
`Company` longtext NULL,
`Location` longtext NULL,
`Start` longtext NULL,
`End` longtext NULL,
`StartDate` longtext NULL,
`EndDate` longtext NULL,
`IsCurrent` tinyint(1) NOT NULL DEFAULT 0,
`BulletsJson` longtext NOT NULL,
`SkillsJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerExperiences_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerExperiences.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerExperiences", "Id");
EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerEducations") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerEducations = conn.CreateCommand();
cmdCareerEducations.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerEducations` (
`Id` int NOT NULL AUTO_INCREMENT,
`Qualification` longtext NULL,
`QualificationLevel` longtext NULL,
`Institution` longtext NULL,
`Location` longtext NULL,
`Start` longtext NULL,
`End` longtext NULL,
`StartDate` longtext NULL,
`EndDate` longtext NULL,
`DetailsJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerEducations_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerEducations.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerEducations", "Id");
EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerSkills") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerSkills = conn.CreateCommand();
cmdCareerSkills.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerSkills` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Category` longtext NULL,
`Proficiency` longtext NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerSkills_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerSkills.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerSkills", "Id");
EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerProjects") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerProjects = conn.CreateCommand();
cmdCareerProjects.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProjects` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Role` longtext NULL,
`Location` longtext NULL,
`Start` longtext NULL,
`End` longtext NULL,
`StartDate` longtext NULL,
`EndDate` longtext NULL,
`BulletsJson` longtext NOT NULL,
`SkillsJson` longtext NOT NULL,
`LinksJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerProjects_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerProjects.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProjects", "Id");
EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerCertifications") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerCertifications = conn.CreateCommand();
cmdCareerCertifications.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerCertifications` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Issuer` longtext NULL,
`Location` longtext NULL,
`Date` longtext NULL,
`DateNormalized` longtext NULL,
`DetailsJson` longtext NOT NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerCertifications_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerCertifications.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerCertifications", "Id");
EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
if (!HasMySqlTable(conn, "CareerLanguages") && HasMySqlTable(conn, "CareerProfiles"))
{
using var cmdCareerLanguages = conn.CreateCommand();
cmdCareerLanguages.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerLanguages` (
`Id` int NOT NULL AUTO_INCREMENT,
`Name` longtext NULL,
`Level` longtext NULL,
`Notes` longtext NULL,
`CareerProfileId` int NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`ItemKey` varchar(255) NOT NULL,
`SortOrder` int NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CareerLanguages_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
);";
cmdCareerLanguages.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerLanguages", "Id");
EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_CareerProfileId", "`CareerProfileId`");
EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id");
EnsureMySqlIndex(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId", "`OwnerUserId`, `JobApplicationId`", unique: true);
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id");
EnsureMySqlIndex(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType", "`OwnerUserId`, `JobApplicationId`, `NoteType`", unique: true);
// 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.
ReconcileCoreAppColumnsMySql(conn);
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvExtractionRunId` int NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvProfileVersion` int NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE `AspNetUsers` ADD COLUMN `AvatarImageDataUrl` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftLinkedAt` datetime NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpSecretEncrypted` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpPendingSecretEncrypted` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpEnabledAtUtc` datetime NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeCustomerId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeCustomerId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionStatus` varchar(64) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
// used to create it too, which made a clean install fail with "Table 'RuleSettings'
// already exists" when Migrate() then tried. Only the default row is seeded here,
// and only once the table exists (pass 3 on a fresh database).
if (HasMySqlTable(conn, "RuleSettings"))
{
using var seedRuleSettings = conn.CreateCommand();
seedRuleSettings.CommandText = @"INSERT INTO `RuleSettings` (`Id`, `AppliedFollowUpDays`, `AppliedGhostDays`, `OfferFollowUpDays`, `OfferGhostDays`, `FeedbackFollowUpDays`, `FeedbackGhostDays`)
SELECT 1, 14, 30, 7, 14, 7, 14
WHERE NOT EXISTS (SELECT 1 FROM `RuleSettings` WHERE `Id` = 1);";
seedRuleSettings.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "UserRuleSettings"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserRuleSettings` (
`OwnerUserId` varchar(255) NOT NULL,
`AppliedFollowUpDays` int NOT NULL,
`AppliedGhostDays` int NOT NULL,
`OfferFollowUpDays` int NOT NULL,
`OfferGhostDays` int NOT NULL,
`FeedbackFollowUpDays` int NOT NULL,
`FeedbackGhostDays` int NOT NULL,
PRIMARY KEY (`OwnerUserId`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "SystemEmailSettings"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `SystemEmailSettings` (
`Id` int NOT NULL,
`Enabled` tinyint(1) NULL,
`SmtpHost` longtext NULL,
`SmtpPort` int NULL,
`SmtpUser` longtext NULL,
`SmtpPassword` longtext NULL,
`From` longtext NULL,
`FromName` longtext NULL,
`SmtpEnableSsl` tinyint(1) NULL,
`SmtpTimeoutMs` int NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CvUploadArtifacts"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvUploadArtifacts` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`OriginalFileName` longtext NOT NULL,
`StoredFileName` longtext NOT NULL,
`MimeType` longtext NOT NULL,
`ByteSize` bigint NOT NULL,
`Sha256` longtext NOT NULL,
`StoragePath` longtext NOT NULL,
`UploadedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CvExtractionRuns") && HasMySqlTable(conn, "CvUploadArtifacts"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvExtractionRuns` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`ArtifactId` int NULL,
`Trigger` longtext NOT NULL,
`ParserVersion` longtext NOT NULL,
`NormalizerVersion` longtext NOT NULL,
`LlmPromptVersion` longtext NOT NULL,
`Status` longtext NOT NULL,
`RawExtractedText` longtext NULL,
`NormalizedText` longtext NULL,
`StructuredProfileJson` longtext NULL,
`ErrorMessage` longtext NULL,
`StartedAtUtc` datetime(6) NOT NULL,
`CompletedAtUtc` datetime(6) NULL,
`AppliedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`ArtifactId`) REFERENCES `CvUploadArtifacts` (`Id`) ON DELETE SET NULL
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "GmailConnections"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `GmailConnections` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`GmailAddress` varchar(512) NOT NULL,
`EncryptedRefreshToken` longtext NOT NULL,
`EncryptedAccessToken` longtext NULL,
`AccessTokenExpiresAt` datetime(6) NULL,
`Scope` longtext NOT NULL,
`ConnectedAt` datetime(6) NOT NULL,
`LastSyncedAt` datetime(6) NULL,
`LastSyncAttemptedAt` datetime(6) NULL,
`LastSyncSucceededAt` datetime(6) NULL,
`LastSyncMode` varchar(255) NULL,
`LastSyncSource` varchar(255) NULL,
`LastSyncStatus` varchar(255) NULL,
`LastSyncError` longtext NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncAttemptedAt` datetime(6) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSucceededAt` datetime(6) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncMode", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncMode` varchar(255) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSource", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSource` varchar(255) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;");
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;");
if (!HasMySqlTable(conn, "MicrosoftGraphConnections"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `MicrosoftGraphConnections` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`MailAddress` varchar(512) NOT NULL,
`EncryptedRefreshToken` longtext NOT NULL,
`EncryptedAccessToken` longtext NULL,
`AccessTokenExpiresAt` datetime(6) NULL,
`Scope` longtext NOT NULL,
`ConnectedAt` datetime(6) NOT NULL,
`LastSyncedAt` datetime(6) NULL,
`LastSyncAttemptedAt` datetime(6) NULL,
`LastSyncSucceededAt` datetime(6) NULL,
`LastSyncMode` varchar(255) NULL,
`LastSyncSource` varchar(255) NULL,
`LastSyncStatus` varchar(255) NULL,
`LastSyncError` longtext NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "ImapConnections"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ImapConnections` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`Host` varchar(255) NOT NULL,
`Port` int NOT NULL,
`UseSsl` tinyint(1) NOT NULL,
`Username` varchar(255) NOT NULL,
`EncryptedPassword` longtext NOT NULL,
`ConnectedAt` datetime(6) NOT NULL,
`LastSyncedAt` datetime(6) NULL,
`LastSyncAttemptedAt` datetime(6) NULL,
`LastSyncSucceededAt` datetime(6) NULL,
`LastSyncMode` varchar(255) NULL,
`LastSyncSource` varchar(255) NULL,
`LastSyncStatus` varchar(255) NULL,
`LastSyncError` longtext NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "TailoredCvDrafts") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`CanonicalProfileVersion` int NULL,
`TemplateId` varchar(100) NOT NULL,
`Headline` longtext NULL,
`SummaryJson` longtext NULL,
`SelectedSkillsJson` longtext NULL,
`ExperienceJson` longtext NULL,
`EducationJson` longtext NULL,
`CustomSectionsJson` longtext NULL,
`RenderOptionsJson` longtext NULL,
`GenerationContextHash` longtext NULL,
`LastGeneratedAtUtc` datetime(6) NULL,
`LastEditedAtUtc` datetime(6) NULL,
`Status` varchar(100) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
);";
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,
`LongTailJson` longtext NOT NULL,
`Version` int NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
// Additive repair for a CareerProfiles table created before LongTailJson was added
// to the model. Without it, /api/cv/outline fails with "Unknown column LongTailJson".
// DEFAULT '' backfills existing rows and matches the non-nullable model property.
EnsureMySqlColumn(conn, "CareerProfiles", "LongTailJson", "ALTER TABLE `CareerProfiles` ADD COLUMN `LongTailJson` longtext NOT NULL DEFAULT '';");
if (!HasMySqlTable(conn, "CareerProfileVersions") && HasMySqlTable(conn, "CareerProfiles"))
{
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") && HasMySqlTable(conn, "JobApplications"))
{
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") && HasMySqlTable(conn, "JobApplications"))
{
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();
}
// Phase 4 CV Builder + Phase 5 AI Workspace. Reconciler-owned rather than
// migration-owned: those migrations were scaffolded against SQLite, so on MariaDB
// they emit TEXT datetimes and a PK with no AUTO_INCREMENT, and the composite
// index over a TEXT column then exceeds MySQL's 3072-byte key limit -- which
// crashed backend startup on prod. Drop any such half-built table (only when it
// holds no rows) and rebuild from the correct MySQL DDL below.
// Children first: CvVariantVersions FKs into CvVariants.
DropMalformedMySqlTable(conn, "CvVariantVersions", "CreatedAtUtc", "datetime");
DropMalformedMySqlTable(conn, "CvVariants", "UpdatedAtUtc", "datetime");
DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime");
DropMalformedMySqlTable(conn, "ApplicationChecklistItems", "CreatedAtUtc", "datetime");
DropMalformedMySqlTable(conn, "CoverLetterVersions", "CreatedAtUtc", "datetime");
DropMalformedMySqlTable(conn, "InterviewPrepItems", "CreatedAtUtc", "datetime");
if (!HasMySqlTable(conn, "CvVariants") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariants` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`PublicSlug` varchar(64) NOT NULL,
`Name` varchar(255) NOT NULL,
`JobApplicationId` int NULL,
`SettingsJson` longtext NOT NULL,
`IsPublic` tinyint(1) NOT NULL,
`Version` int NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CvVariants_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE SET NULL
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CvVariantVersions") && HasMySqlTable(conn, "CvVariants"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariantVersions` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`CvVariantId` int NOT NULL,
`Version` int NOT NULL,
`SettingsJson` longtext NOT NULL,
`Source` varchar(100) NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CvVariantVersions_CvVariants_CvVariantId` FOREIGN KEY (`CvVariantId`) REFERENCES `CvVariants` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "AiInteractions") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiInteractions` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`Module` varchar(64) NOT NULL,
`Mode` varchar(64) NULL,
`Title` varchar(255) NOT NULL,
`Provider` varchar(100) NOT NULL,
`ResultJson` longtext NOT NULL,
`InputCharacterCount` int NOT NULL DEFAULT 0,
`OutputCharacterCount` int NOT NULL DEFAULT 0,
`EstimatedTokenCount` int NOT NULL DEFAULT 0,
`CreatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_AiInteractions_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "ApplicationChecklistItems") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ApplicationChecklistItems` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`SystemKey` varchar(64) NULL,
`AutoSignal` varchar(64) NULL,
`Title` varchar(255) NOT NULL,
`Description` longtext NULL,
`Category` varchar(32) NOT NULL,
`Status` varchar(32) NOT NULL,
`Section` varchar(64) NULL,
`SortOrder` int NOT NULL,
`IsSystemGenerated` tinyint(1) NOT NULL,
`IsAutoCompleted` tinyint(1) NOT NULL,
`CompletedAt` datetime(6) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_ApplicationChecklistItems_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "CoverLetterVersions") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CoverLetterVersions` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`Version` int NOT NULL,
`Text` longtext NOT NULL,
`Source` varchar(32) NOT NULL,
`AiAction` varchar(32) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_CoverLetterVersions_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "InterviewPrepItems") && HasMySqlTable(conn, "JobApplications"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepItems` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`JobApplicationId` int NOT NULL,
`Category` varchar(32) NOT NULL,
`Title` varchar(500) NOT NULL,
`Content` longtext NULL,
`Source` varchar(16) NOT NULL,
`IsPrepared` tinyint(1) NOT NULL,
`SortOrder` int NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UpdatedAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`),
CONSTRAINT `FK_InterviewPrepItems_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepItems", "Id");
EnsureMySqlIndex(conn, "InterviewPrepItems", "IX_InterviewPrepItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CoverLetterVersions", "Id");
EnsureMySqlIndex(conn, "CoverLetterVersions", "IX_CoverLetterVersions_Owner_Job_Version", "`OwnerUserId`, `JobApplicationId`, `Version`");
EnsureMySqlAutoIncrementPrimaryKey(conn, "ApplicationChecklistItems", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariantVersions", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiInteractions", "Id");
EnsureMySqlColumn(conn, "AiInteractions", "InputCharacterCount", "ALTER TABLE `AiInteractions` ADD COLUMN `InputCharacterCount` int NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "AiInteractions", "OutputCharacterCount", "ALTER TABLE `AiInteractions` ADD COLUMN `OutputCharacterCount` int NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "AiInteractions", "EstimatedTokenCount", "ALTER TABLE `AiInteractions` ADD COLUMN `EstimatedTokenCount` int NOT NULL DEFAULT 0;");
foreach (var (ixTable, ixName, ixColumns, ixUnique) in new[]
{
("CvVariants", "IX_CvVariants_JobApplicationId", "`JobApplicationId`", false),
("CvVariants", "IX_CvVariants_OwnerUserId_UpdatedAtUtc", "`OwnerUserId`, `UpdatedAtUtc`", false),
("CvVariants", "IX_CvVariants_PublicSlug", "`PublicSlug`", true),
("CvVariantVersions", "IX_CvVariantVersions_CvVariantId_Version", "`CvVariantId`, `Version`", false),
("AiInteractions", "IX_AiInteractions_JobApplicationId", "`JobApplicationId`", false),
("AiInteractions", "IX_AiInteractions_Owner_Job_Module_Created", "`OwnerUserId`, `JobApplicationId`, `Module`, `CreatedAtUtc`", false),
("ApplicationChecklistItems", "IX_ApplicationChecklistItems_JobApplicationId_SystemKey", "`JobApplicationId`, `SystemKey`", true),
("ApplicationChecklistItems", "IX_ApplicationChecklistItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`", false),
})
{
EnsureMySqlIndex(conn, ixTable, ixName, ixColumns, ixUnique);
}
EnsureMySqlIndex(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId", "`OwnerUserId`", unique: true);
EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`");
if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes` (
`Id` int NOT NULL AUTO_INCREMENT,
`UserId` varchar(255) NOT NULL,
`CodeHash` varchar(255) NOT NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`UsedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id");
EnsureMySqlIndex(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc", "`UserId`, `UsedAtUtc`");
if (!HasMySqlTable(conn, "TrustedDevices"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TrustedDevices` (
`Id` int NOT NULL AUTO_INCREMENT,
`UserId` varchar(255) NOT NULL,
`TokenHash` varchar(255) NOT NULL,
`DeviceLabel` varchar(255) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`LastSeenAtUtc` datetime(6) NOT NULL,
`ExpiresAtUtc` datetime(6) NOT NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id");
EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_UserId", "`UserId`");
EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash", "`TokenHash`");
if (!HasMySqlTable(conn, "UserSessions"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserSessions` (
`Id` varchar(64) NOT NULL,
`UserId` varchar(255) NOT NULL,
`DeviceLabel` varchar(255) NULL,
`CreatedAtUtc` datetime(6) NOT NULL,
`LastSeenAtUtc` datetime(6) NOT NULL,
`ExpiresAtUtc` datetime(6) NOT NULL,
`RevokedAtUtc` datetime(6) NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
EnsureMySqlIndex(conn, "UserSessions", "IX_UserSessions_UserId", "`UserId`");
// Schema reconciliation must never crash app startup: an index that fails
// (e.g. combined key exceeds MySQL's 3072-byte limit because an older
// migration made OwnerUserId wider than the varchar(255) this reconciler
// assumes) is logged and skipped rather than taking prod down. OwnerUserId
// is prefix-indexed at 191 chars (safe under utf8mb4's 767-byte legacy
// per-column key limit, and far longer than the GUID-like Identity ids
// actually stored there) so composite indexes stay well under the cap
// regardless of the column's declared width.
// Same guarded creation, but non-fatal: these are optimisation indexes on
// migration-owned tables, so a failure must degrade performance, never boot.
void TryCreateIndex(string table, string indexName, string columnsSql, bool unique = false)
{
try
{
EnsureMySqlIndex(conn, table, indexName, columnsSql, unique);
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Skipping index {Index} on {Table} during startup reconciliation.", indexName, table);
}
}
void TryCreateUniqueIndex(string table, string indexName, string columnsSql) =>
TryCreateIndex(table, indexName, columnsSql, unique: true);
TryCreateIndex("Companies", "IX_Companies_OwnerUserId", "`OwnerUserId`(191)");
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId", "`OwnerUserId`(191)");
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt).
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted", "`OwnerUserId`(191), `IsDeleted`");
// FollowUpAt is `text` on MariaDB (the migration was scaffolded against SQLite, which
// stores DateTimeOffset as TEXT). A text column cannot be indexed without a prefix
// length, so without one this index ALWAYS failed the 3072-byte key check, was caught
// and skipped on every boot, and left the follow-up reminder query unindexed — while
// logging a "Specified key was too long" line the deploy runbook flags as a rollback
// signal. Prefix it like Status(50) below. ISO-8601 date strings sort lexicographically,
// so a 20-char prefix ("YYYY-MM-DD HH:MM:SS") keeps the index useful for the reminder scan.
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`(20)");
// Status is longtext in MySQL (see JobTrackerContext.OnModelCreating), so it
// needs an explicit prefix length to be indexable under MariaDB's key-length rules.
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted_Status", "`OwnerUserId`(191), `IsDeleted`, `Status`(50)");
TryCreateIndex("Correspondences", "IX_Correspondences_JobApplicationId", "`JobApplicationId`");
TryCreateIndex("JobEvents", "IX_JobEvents_JobApplicationId", "`JobApplicationId`");
TryCreateIndex("CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc", "`OwnerUserId`(191), `UploadedAtUtc`");
TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc", "`OwnerUserId`(191), `StartedAtUtc`");
TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId", "`ArtifactId`");
TryCreateIndex("GmailConnections", "IX_GmailConnections_OwnerUserId", "`OwnerUserId`(191)");
TryCreateUniqueIndex("GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress", "`OwnerUserId`(191), `GmailAddress`(191)");
TryCreateIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId", "`OwnerUserId`(191)");
TryCreateUniqueIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress", "`OwnerUserId`(191), `MailAddress`(191)");
TryCreateUniqueIndex("ImapConnections", "IX_ImapConnections_OwnerUserId", "`OwnerUserId`(191)");
TryCreateUniqueIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId", "`OwnerUserId`(191), `JobApplicationId`");
TryCreateIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId", "`JobApplicationId`");
}
}
}
// 1. Reconcile what already exists, and create the reconciler-owned tables. This has to run
// BEFORE Migrate(): legacy schemas need repairing so migrations do not collide with
// them, and AddCareerProfileRelationalChildren adds children that reference
// CareerProfiles, a reconciler-owned table. Anything that references a migration-owned
// table is skipped here (the parent does not exist yet) and picked up in pass 3.
ReconcileSchema();
// 2. Apply one migration at a time, reconciling after each. Some historical migrations
// rebuild tables using columns owned by the reconciler, so a fresh database needs
// those columns added after the base table appears and before a later migration reads it.
try
{
using var migrationScope = app.Services.CreateScope();
var migrationDb = migrationScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var migrator = migrationDb.Database.GetService<IMigrator>();
while (migrationDb.Database.GetPendingMigrations().FirstOrDefault() is { } migration)
{
migrator.Migrate(migration);
ReconcileSchema();
}
}
catch (Exception ex)
{
app.Logger.LogError(ex, "Database migration failed during startup initialization.");
throw;
}
// 3. Reconcile again, now that the migration-owned tables exist: creates the
// reconciler-owned tables that reference them and applies the index / AUTO_INCREMENT
// repairs that pass 1 had to skip. Idempotent, so on an existing database it is a scan.
ReconcileSchema();
// Optional: seed an initial admin user for local username/password login.
// Set Auth:AdminEmail and Auth:AdminPassword to enable.
var adminEmail = (app.Configuration["Auth:AdminEmail"] ?? "").Trim();
var adminPassword = (app.Configuration["Auth:AdminPassword"] ?? "").Trim();
if (!string.IsNullOrWhiteSpace(adminEmail) && !string.IsNullOrWhiteSpace(adminPassword))
{
using var adminScope = app.Services.CreateScope();
var adminDb = adminScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var adminUsers = adminScope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var adminRoles = adminScope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
const string adminRole = "Admin";
if (!adminRoles.RoleExistsAsync(adminRole).GetAwaiter().GetResult())
{
adminRoles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult();
}
var existing = adminUsers.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
if (existing is null)
{
var u = new ApplicationUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true };
var created = adminUsers.CreateAsync(u, adminPassword).GetAwaiter().GetResult();
if (created.Succeeded)
{
adminUsers.AddToRoleAsync(u, adminRole).GetAwaiter().GetResult();
app.Logger.LogInformation("Seeded admin user: {Email}", adminEmail);
}
else
{
app.Logger.LogWarning("Failed to seed admin user: {Errors}", string.Join("; ", created.Errors.Select(e => e.Description)));
}
}
else
{
var inRole = adminUsers.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult();
if (!inRole) adminUsers.AddToRoleAsync(existing, adminRole).GetAwaiter().GetResult();
}
// One-time claim of legacy data for the admin user so enabling auth doesn't "hide" existing records.
var admin = adminUsers.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
if (admin is not null)
{
try
{
using var conn = adminDb.Database.GetDbConnection();
conn.Open();
static bool ColumnExists(DbConnection c, string providerName, string table, string column)
{
using var cmd = c.CreateCommand();
if (providerName is "mysql" or "mariadb")
{
var databaseName = c.Database;
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
var schemaParam = cmd.CreateParameter();
schemaParam.ParameterName = "@schema";
schemaParam.Value = databaseName;
cmd.Parameters.Add(schemaParam);
var tableParam = cmd.CreateParameter();
tableParam.ParameterName = "@table";
tableParam.Value = table;
cmd.Parameters.Add(tableParam);
var columnParam = cmd.CreateParameter();
columnParam.ParameterName = "@column";
columnParam.Value = column;
cmd.Parameters.Add(columnParam);
}
else
{
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
}
return cmd.ExecuteScalar() is not null;
}
var companyOwnershipExists = ColumnExists(conn, provider, "Companies", "OwnerUserId");
var jobOwnershipExists = ColumnExists(conn, provider, "JobApplications", "OwnerUserId");
var opportunityOwnershipExists = ColumnExists(conn, provider, "Jobs", "OwnerUserId");
if (companyOwnershipExists || jobOwnershipExists || opportunityOwnershipExists)
{
if (companyOwnershipExists)
{
adminDb.Database.ExecuteSqlRaw("UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
}
if (jobOwnershipExists)
{
adminDb.Database.ExecuteSqlRaw("UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
}
if (opportunityOwnershipExists)
{
adminDb.Database.ExecuteSqlRaw("UPDATE Jobs SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
}
}
}
catch (Exception ex)
{
app.Logger.LogWarning(ex, "Skipping legacy ownership claim because the current schema does not support it yet.");
}
}
}
}
bool CoreSchemaReady(DbConnection connection, string providerName)
{
using var cmd = connection.CreateCommand();
if (providerName is "mysql" or "mariadb")
{
cmd.CommandText = "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ('JobApplications', 'RuleSettings');";
return Convert.ToInt32(cmd.ExecuteScalar() ?? 0) == 2;
}
cmd.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('JobApplications', 'RuleSettings');";
return Convert.ToInt32(cmd.ExecuteScalar() ?? 0) == 2;
}
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var runtimeProvider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
using var conn = db.Database.GetDbConnection();
conn.Open();
if (!CoreSchemaReady(conn, runtimeProvider))
{
app.Logger.LogWarning("Core schema is incomplete after startup initialization. Background services will remain paused until required tables exist.");
return Task.CompletedTask;
}
// On a brand-new database, the ad-hoc-column reconciliation above ran before
// Migrate() created JobApplications/Correspondences, so every EnsureColumn call
// no-opped. Now that CoreSchemaReady confirms the tables exist (created either just
// now by Migrate(), or already, on a prior boot), re-run it -- idempotent, so this is
// free on every boot except the very first one, where it's required.
if (runtimeProvider is "mysql" or "mariadb")
{
ReconcileCoreAppColumnsMySql(conn);
}
else
{
ReconcileCoreAppColumns(conn);
}
}
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var backfilled = JobOpportunitySync.BackfillLegacyAsync(db).GetAwaiter().GetResult();
if (backfilled > 0)
app.Logger.LogInformation("Backfilled {Count} legacy job opportunities.", backfilled);
}
var readiness = app.Services.GetRequiredService<IStartupReadiness>();
readiness.MarkReady();
return Task.CompletedTask;
}
}