@@ -9,6 +9,186 @@ 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 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).
@@ -130,50 +310,6 @@ public static class StartupInitializationExtensions
using DbConnection conn = db . Database . GetDbConnection ( ) ;
conn . Open ( ) ;
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 ;
}
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 ;
}
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 ;
}
static void Exec ( DbConnection c , string sql )
{
using var cmd = c . CreateCommand ( ) ;
cmd . CommandText = sql ;
cmd . ExecuteNonQuery ( ) ;
}
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 ) ;
}
static void EnsureIdentityTables ( DbConnection c )
{
// EF migrations are used for the app schema. In some environments `dotnet ef` isn’ t available,
@@ -530,39 +666,10 @@ public static class StartupInitializationExtensions
}
// Some dev DBs may not match the "legacy" fingerprint above but still lack
// the ShortSummary column. Ensure it exists unconditionally if missing.
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;" ) ;
// Structured salary fields (EF maps decimal to TEXT on SQLite).
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;" ) ;
// Ensure ownership columns exist even on non-legacy DBs.
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;" ) ;
// Backfill: historically the only import source was Gmail (rows with an
// ExternalThreadId); everything else was hand-entered. Idempotent — only touches
// rows the app hasn't tagged yet.
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;" ) ;
// 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
@@ -586,18 +693,6 @@ public static class StartupInitializationExtensions
conn . Open ( ) ;
EnsureIdentityTablesMySql ( conn ) ;
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 ;
}
static bool MySqlIndexExists ( DbConnection c , string table , string indexName )
{
using var cmd = c . CreateCommand ( ) ;
@@ -610,28 +705,6 @@ public static class StartupInitializationExtensions
return cmd . ExecuteScalar ( ) is not null ;
}
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 ;
}
static void EnsureMySqlColumn ( DbConnection c , string table , string column , string ddl )
{
using var existsCmd = c . CreateCommand ( ) ;
existsCmd . CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;" ;
var ep1 = existsCmd . CreateParameter ( ) ; ep1 . ParameterName = "@schema" ; ep1 . Value = c . Database ; existsCmd . Parameters . Add ( ep1 ) ;
var ep2 = existsCmd . CreateParameter ( ) ; ep2 . ParameterName = "@table" ; ep2 . Value = table ; existsCmd . Parameters . Add ( ep2 ) ;
if ( existsCmd . ExecuteScalar ( ) is null ) return ;
if ( MySqlColumnExists ( c , table , column ) ) return ;
using var ddlCmd = c . CreateCommand ( ) ;
ddlCmd . CommandText = ddl ;
ddlCmd . ExecuteNonQuery ( ) ;
}
static bool MySqlIntPrimaryKeyIsAutoIncrement ( DbConnection c , string table , string column )
{
@@ -671,63 +744,10 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey ( conn , "CvExtractionRuns" , "Id" ) ;
EnsureMySqlAutoIncrementPrimaryKey ( conn , "TailoredCvDrafts" , "Id" ) ;
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;" ) ;
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;" ) ;
// 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;" ) ;
@@ -1185,6 +1205,20 @@ public static class StartupInitializationExtensions
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 ) ;
}
}
var readiness = app . Services . GetRequiredService < IStartupReadiness > ( ) ;