058d13de47
Complete schema ownership by moving all ASP.NET Identity tables to an additive provider-aware migration. Preserve credentials, security state, preferences, roles, claims, external logins, tokens, indexes, and cascades.
884 lines
65 KiB
C#
884 lines
65 KiB
C#
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";
|
|
|
|
// 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<JobTrackerContext>();
|
|
if (useSqliteBootstrap)
|
|
{
|
|
var migrator = migrationDb.Database.GetService<IMigrator>();
|
|
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<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;
|
|
}
|
|
}
|