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(); var paths = scope.ServiceProvider.GetRequiredService(); var users = scope.ServiceProvider.GetRequiredService>(); var roles = scope.ServiceProvider.GetRequiredService>(); var provider = (app.Configuration["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant(); var useSqliteBootstrap = provider is not "mysql" and not "mariadb"; // The schema reconciler. Runs TWICE: once before Migrate() and once after. // // Before: legacy databases need hand-added columns repaired and the legacy // migration-history stamp written, or Migrate() can collide with them. // After: on a brand-new database the migration-owned tables did not exist during the first // pass, so index, column, and AUTO_INCREMENT repairs were skipped. The second pass finds // the migration-created tables and finishes compatibility repair. // // 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(); 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;"); // UiLanguage is migration-owned (AddUiLanguagePreference). Adding it here before // the per-migration loop makes a fresh database fail when that migration runs. static void ReconcileGmailConnectionColumns(DbConnection c) { 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;"); } // Preserve the one historical additive column repair while migrations own table creation. static void ReconcileCareerProfileColumns(DbConnection c) { EnsureColumn(c, "CareerProfiles", "LongTailJson", """ALTER TABLE "CareerProfiles" ADD COLUMN "LongTailJson" TEXT NOT NULL DEFAULT '';"""); } static void ReconcileAiInteractionUsageColumns(DbConnection c) { 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;"); } ReconcileGmailConnectionColumns(conn); ReconcileCareerProfileColumns(conn); ReconcileAiInteractionUsageColumns(conn); // Once the base app tables exist, stamp the 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(); 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`"); // Guarded historical shape/index repairs only. Creation belongs to the current // provider-aware Career Profile adoption migration. EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerExperiences", "Id"); EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_CareerProfileId", "`CareerProfileId`"); EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerEducations", "Id"); EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_CareerProfileId", "`CareerProfileId`"); EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerSkills", "Id"); EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_CareerProfileId", "`CareerProfileId`"); EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProjects", "Id"); EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_CareerProfileId", "`CareerProfileId`"); EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerCertifications", "Id"); EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_CareerProfileId", "`CareerProfileId`"); EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`"); 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;"); // UiLanguage is migration-owned; the final reconciliation pass observes it // after AddUiLanguagePreference instead of racing the migration. // 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(); } 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;"); // 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 '';"); // Historical Phase 4/5 migrations were scaffolded against SQLite, so on MariaDB // they could emit TEXT datetimes and a PK with no AUTO_INCREMENT, then fail while // indexing TEXT columns. Drop only malformed empty tables before the current // provider-aware adoption migrations run; populated tables are never replaced. // 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"); 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`"); EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id"); EnsureMySqlIndex(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc", "`UserId`, `UsedAtUtc`"); EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id"); EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_UserId", "`UserId`"); EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash", "`TokenHash`"); 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. This runs before Migrate() so legacy schemas are // repaired before migrations inspect them. 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(); if (useSqliteBootstrap) { var migrator = migrationDb.Database.GetService(); while (migrationDb.Database.GetPendingMigrations().FirstOrDefault() is { } migration) { migrator.Migrate(migration); ReconcileSchema(); } } else { // MariaDB ALTER operations do not rebuild tables from a snapshot, so they do // not need SQLite's per-migration reconciliation. Reconciling between its // historical migrations can add a later column (for example Companies.Source) // immediately before the migration that owns it, producing a duplicate-column // failure on a clean database. Apply the chain first, then use the common final // reconciliation pass for provider-safe legacy repairs. migrationDb.Database.Migrate(); } } catch (Exception ex) { app.Logger.LogError(ex, "Database migration failed during startup initialization."); throw; } // 3. Reconcile again, now that migration-owned tables exist, applying the index and // AUTO_INCREMENT repairs pass 1 had to skip. Existing databases receive a no-op 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(); var adminUsers = adminScope.ServiceProvider.GetRequiredService>(); var adminRoles = adminScope.ServiceProvider.GetRequiredService>(); 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(); 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(); var backfilled = JobOpportunitySync.BackfillLegacyAsync(db).GetAwaiter().GetResult(); if (backfilled > 0) app.Logger.LogInformation("Backfilled {Count} legacy job opportunities.", backfilled); } var readiness = app.Services.GetRequiredService(); readiness.MarkReady(); return Task.CompletedTask; } }