ce76046a29
- consolidate API ownership and remove dead vendor code - add Stripe billing, learning paths, and public CV hardening - add migration, recovery, security, audit, and browser gates
2182 lines
128 KiB
C#
2182 lines
128 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";
|
||
|
||
static void EnsureIdentityTablesMySql(DbConnection c)
|
||
{
|
||
using var cmd = c.CreateCommand();
|
||
cmd.CommandText = @"
|
||
CREATE TABLE IF NOT EXISTS `AspNetRoles` (
|
||
`Id` varchar(255) NOT NULL,
|
||
`Name` varchar(256) NULL,
|
||
`NormalizedName` varchar(256) NULL,
|
||
`ConcurrencyStamp` longtext NULL,
|
||
PRIMARY KEY (`Id`)
|
||
) CHARACTER SET=utf8mb4;
|
||
|
||
CREATE TABLE IF NOT EXISTS `AspNetUsers` (
|
||
`Id` varchar(255) NOT NULL,
|
||
`UserName` varchar(256) NULL,
|
||
`NormalizedUserName` varchar(256) NULL,
|
||
`Email` varchar(256) NULL,
|
||
`NormalizedEmail` varchar(256) NULL,
|
||
`EmailConfirmed` tinyint(1) NOT NULL,
|
||
`PasswordHash` longtext NULL,
|
||
`SecurityStamp` longtext NULL,
|
||
`ConcurrencyStamp` longtext NULL,
|
||
`PhoneNumber` longtext NULL,
|
||
`PhoneNumberConfirmed` tinyint(1) NOT NULL,
|
||
`TwoFactorEnabled` tinyint(1) NOT NULL,
|
||
`LockoutEnd` datetime(6) NULL,
|
||
`LockoutEnabled` tinyint(1) NOT NULL,
|
||
`AccessFailedCount` int NOT NULL,
|
||
`FirstName` longtext NULL,
|
||
`LastName` longtext NULL,
|
||
`DisplayName` longtext NULL,
|
||
`ProfileCvText` longtext NULL,
|
||
`ProfileCvStructureJson` longtext NULL,
|
||
`CurrentCvUploadArtifactId` int NULL,
|
||
`CurrentCvExtractionRunId` int NULL,
|
||
`CurrentCvProfileVersion` int NULL,
|
||
`AvatarImageDataUrl` longtext NULL,
|
||
`GoogleSubject` longtext NULL,
|
||
`GoogleEmail` longtext NULL,
|
||
`GoogleLinkedAt` datetime(6) NULL,
|
||
`MicrosoftSubject` longtext NULL,
|
||
`MicrosoftEmail` longtext NULL,
|
||
`MicrosoftLinkedAt` datetime(6) NULL,
|
||
`TotpSecretEncrypted` longtext NULL,
|
||
`TotpPendingSecretEncrypted` longtext NULL,
|
||
`TotpEnabledAtUtc` datetime(6) NULL,
|
||
PRIMARY KEY (`Id`)
|
||
) CHARACTER SET=utf8mb4;
|
||
|
||
CREATE TABLE IF NOT EXISTS `AspNetRoleClaims` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`RoleId` varchar(255) NOT NULL,
|
||
`ClaimType` longtext NULL,
|
||
`ClaimValue` longtext NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_AspNetRoleClaims_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE
|
||
) CHARACTER SET=utf8mb4;
|
||
|
||
CREATE TABLE IF NOT EXISTS `AspNetUserClaims` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`UserId` varchar(255) NOT NULL,
|
||
`ClaimType` longtext NULL,
|
||
`ClaimValue` longtext NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_AspNetUserClaims_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
|
||
) CHARACTER SET=utf8mb4;
|
||
|
||
CREATE TABLE IF NOT EXISTS `AspNetUserLogins` (
|
||
`LoginProvider` varchar(255) NOT NULL,
|
||
`ProviderKey` varchar(255) NOT NULL,
|
||
`ProviderDisplayName` longtext NULL,
|
||
`UserId` varchar(255) NOT NULL,
|
||
PRIMARY KEY (`LoginProvider`, `ProviderKey`),
|
||
CONSTRAINT `FK_AspNetUserLogins_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
|
||
) CHARACTER SET=utf8mb4;
|
||
|
||
CREATE TABLE IF NOT EXISTS `AspNetUserRoles` (
|
||
`UserId` varchar(255) NOT NULL,
|
||
`RoleId` varchar(255) NOT NULL,
|
||
PRIMARY KEY (`UserId`, `RoleId`),
|
||
CONSTRAINT `FK_AspNetUserRoles_AspNetRoles_RoleId` FOREIGN KEY (`RoleId`) REFERENCES `AspNetRoles` (`Id`) ON DELETE CASCADE,
|
||
CONSTRAINT `FK_AspNetUserRoles_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
|
||
) CHARACTER SET=utf8mb4;
|
||
|
||
CREATE TABLE IF NOT EXISTS `AspNetUserTokens` (
|
||
`UserId` varchar(255) NOT NULL,
|
||
`LoginProvider` varchar(255) NOT NULL,
|
||
`Name` varchar(255) NOT NULL,
|
||
`Value` longtext NULL,
|
||
PRIMARY KEY (`UserId`, `LoginProvider`, `Name`),
|
||
CONSTRAINT `FK_AspNetUserTokens_AspNetUsers_UserId` FOREIGN KEY (`UserId`) REFERENCES `AspNetUsers` (`Id`) ON DELETE CASCADE
|
||
) CHARACTER SET=utf8mb4;
|
||
|
||
CREATE UNIQUE INDEX IF NOT EXISTS `RoleNameIndex` ON `AspNetRoles` (`NormalizedName`);
|
||
CREATE INDEX IF NOT EXISTS `IX_AspNetRoleClaims_RoleId` ON `AspNetRoleClaims` (`RoleId`);
|
||
CREATE INDEX IF NOT EXISTS `EmailIndex` ON `AspNetUsers` (`NormalizedEmail`);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS `UserNameIndex` ON `AspNetUsers` (`NormalizedUserName`);
|
||
CREATE INDEX IF NOT EXISTS `IX_AspNetUserClaims_UserId` ON `AspNetUserClaims` (`UserId`);
|
||
CREATE INDEX IF NOT EXISTS `IX_AspNetUserLogins_UserId` ON `AspNetUserLogins` (`UserId`);
|
||
CREATE INDEX IF NOT EXISTS `IX_AspNetUserRoles_RoleId` ON `AspNetUserRoles` (`RoleId`);
|
||
";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
// The schema reconciler. Runs TWICE: once before Migrate() and once after.
|
||
//
|
||
// Before: legacy databases need their Identity tables and hand-added columns present, and
|
||
// the legacy migration-history stamp written, or Migrate() collides with them.
|
||
// After: on a brand-new database the migration-owned tables did not exist during the first
|
||
// pass, so everything that depends on them (reconciler-owned tables that FK into
|
||
// JobApplications, index and AUTO_INCREMENT repairs) was skipped. The second pass finds them
|
||
// and finishes the job.
|
||
//
|
||
// Every statement in here is existence-guarded, so running it twice is a no-op scan on an
|
||
// already-correct database. docs/infrastructure/database-ownership.md.
|
||
void ReconcileSchema()
|
||
{
|
||
if (useSqliteBootstrap)
|
||
{
|
||
// Bridge older dev DBs that were modified via ad-hoc ALTER TABLE (before migrations were applied).
|
||
// If the schema already contains the columns added by migration 20260310195000, record that migration
|
||
// so EF doesn't try to apply it again and fail on duplicate columns.
|
||
const string legacyMigrationId = "20260310195000_AddJobFieldsAndSoftDelete";
|
||
const string legacyProductVersion = "7.0.17";
|
||
|
||
// Not a `using`: the connection belongs to the DbContext, and ReconcileSchema runs more
|
||
// than once — disposing it here made the second pass throw ObjectDisposedException.
|
||
DbConnection conn = db.Database.GetDbConnection();
|
||
// ReconcileSchema runs twice; the second pass may inherit an already-open connection.
|
||
if (conn.State != System.Data.ConnectionState.Open) conn.Open();
|
||
|
||
static void EnsureIdentityTables(DbConnection c)
|
||
{
|
||
// EF migrations are used for the app schema. In some environments `dotnet ef` isn’t available,
|
||
// so create the ASP.NET Core Identity tables directly if they don’t exist yet.
|
||
if (HasTable(c, "AspNetUsers")) return;
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "AspNetRoles" (
|
||
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetRoles" PRIMARY KEY,
|
||
"Name" TEXT NULL,
|
||
"NormalizedName" TEXT NULL,
|
||
"ConcurrencyStamp" TEXT NULL
|
||
);
|
||
""");
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "AspNetUsers" (
|
||
"Id" TEXT NOT NULL CONSTRAINT "PK_AspNetUsers" PRIMARY KEY,
|
||
"UserName" TEXT NULL,
|
||
"NormalizedUserName" TEXT NULL,
|
||
"Email" TEXT NULL,
|
||
"NormalizedEmail" TEXT NULL,
|
||
"EmailConfirmed" INTEGER NOT NULL,
|
||
"PasswordHash" TEXT NULL,
|
||
"SecurityStamp" TEXT NULL,
|
||
"ConcurrencyStamp" TEXT NULL,
|
||
"PhoneNumber" TEXT NULL,
|
||
"PhoneNumberConfirmed" INTEGER NOT NULL,
|
||
"TwoFactorEnabled" INTEGER NOT NULL,
|
||
"LockoutEnd" TEXT NULL,
|
||
"LockoutEnabled" INTEGER NOT NULL,
|
||
"AccessFailedCount" INTEGER NOT NULL,
|
||
"FirstName" TEXT NULL,
|
||
"LastName" TEXT NULL,
|
||
"DisplayName" TEXT NULL,
|
||
"ProfileCvText" TEXT NULL,
|
||
"ProfileCvStructureJson" TEXT NULL,
|
||
"CurrentCvUploadArtifactId" INTEGER NULL,
|
||
"CurrentCvExtractionRunId" INTEGER NULL,
|
||
"CurrentCvProfileVersion" INTEGER NULL,
|
||
"AvatarImageDataUrl" TEXT NULL,
|
||
"GoogleSubject" TEXT NULL,
|
||
"GoogleEmail" TEXT NULL,
|
||
"GoogleLinkedAt" TEXT NULL,
|
||
"MicrosoftSubject" TEXT NULL,
|
||
"MicrosoftEmail" TEXT NULL,
|
||
"MicrosoftLinkedAt" TEXT NULL,
|
||
"TotpSecretEncrypted" TEXT NULL,
|
||
"TotpPendingSecretEncrypted" TEXT NULL,
|
||
"TotpEnabledAtUtc" TEXT NULL
|
||
);
|
||
""");
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "AspNetRoleClaims" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetRoleClaims" PRIMARY KEY AUTOINCREMENT,
|
||
"RoleId" TEXT NOT NULL,
|
||
"ClaimType" TEXT NULL,
|
||
"ClaimValue" TEXT NULL,
|
||
CONSTRAINT "FK_AspNetRoleClaims_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "AspNetUserClaims" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_AspNetUserClaims" PRIMARY KEY AUTOINCREMENT,
|
||
"UserId" TEXT NOT NULL,
|
||
"ClaimType" TEXT NULL,
|
||
"ClaimValue" TEXT NULL,
|
||
CONSTRAINT "FK_AspNetUserClaims_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "AspNetUserLogins" (
|
||
"LoginProvider" TEXT NOT NULL,
|
||
"ProviderKey" TEXT NOT NULL,
|
||
"ProviderDisplayName" TEXT NULL,
|
||
"UserId" TEXT NOT NULL,
|
||
CONSTRAINT "PK_AspNetUserLogins" PRIMARY KEY ("LoginProvider", "ProviderKey"),
|
||
CONSTRAINT "FK_AspNetUserLogins_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "AspNetUserRoles" (
|
||
"UserId" TEXT NOT NULL,
|
||
"RoleId" TEXT NOT NULL,
|
||
CONSTRAINT "PK_AspNetUserRoles" PRIMARY KEY ("UserId", "RoleId"),
|
||
CONSTRAINT "FK_AspNetUserRoles_AspNetRoles_RoleId" FOREIGN KEY ("RoleId") REFERENCES "AspNetRoles" ("Id") ON DELETE CASCADE,
|
||
CONSTRAINT "FK_AspNetUserRoles_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "AspNetUserTokens" (
|
||
"UserId" TEXT NOT NULL,
|
||
"LoginProvider" TEXT NOT NULL,
|
||
"Name" TEXT NOT NULL,
|
||
"Value" TEXT NULL,
|
||
CONSTRAINT "PK_AspNetUserTokens" PRIMARY KEY ("UserId", "LoginProvider", "Name"),
|
||
CONSTRAINT "FK_AspNetUserTokens_AspNetUsers_UserId" FOREIGN KEY ("UserId") REFERENCES "AspNetUsers" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "RoleNameIndex" ON "AspNetRoles" ("NormalizedName");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetRoleClaims_RoleId" ON "AspNetRoleClaims" ("RoleId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "EmailIndex" ON "AspNetUsers" ("NormalizedEmail");""");
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "UserNameIndex" ON "AspNetUsers" ("NormalizedUserName");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserClaims_UserId" ON "AspNetUserClaims" ("UserId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserLogins_UserId" ON "AspNetUserLogins" ("UserId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AspNetUserRoles_RoleId" ON "AspNetUserRoles" ("RoleId");""");
|
||
}
|
||
|
||
EnsureIdentityTables(conn);
|
||
EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvStructureJson TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvUploadArtifactId INTEGER NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvExtractionRunId INTEGER NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE AspNetUsers ADD COLUMN CurrentCvProfileVersion INTEGER NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE AspNetUsers ADD COLUMN AvatarImageDataUrl TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftSubject TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftEmail TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN MicrosoftLinkedAt TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpSecretEncrypted TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpPendingSecretEncrypted TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE AspNetUsers ADD COLUMN TotpEnabledAtUtc TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "StripeCustomerId", "ALTER TABLE AspNetUsers ADD COLUMN StripeCustomerId TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionId TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionStatus TEXT NULL;");
|
||
EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
|
||
|
||
static void EnsureUserRuleSettingsTable(DbConnection c)
|
||
{
|
||
if (HasTable(c, "UserRuleSettings")) return;
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "UserRuleSettings" (
|
||
"OwnerUserId" TEXT NOT NULL CONSTRAINT "PK_UserRuleSettings" PRIMARY KEY,
|
||
"AppliedFollowUpDays" INTEGER NOT NULL,
|
||
"AppliedGhostDays" INTEGER NOT NULL,
|
||
"OfferFollowUpDays" INTEGER NOT NULL,
|
||
"OfferGhostDays" INTEGER NOT NULL,
|
||
"FeedbackFollowUpDays" INTEGER NOT NULL,
|
||
"FeedbackGhostDays" INTEGER NOT NULL
|
||
);
|
||
""");
|
||
}
|
||
|
||
EnsureUserRuleSettingsTable(conn);
|
||
|
||
static void EnsureGmailConnectionsTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailConnections" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"GmailAddress" TEXT NOT NULL,
|
||
"EncryptedRefreshToken" TEXT NOT NULL,
|
||
"EncryptedAccessToken" TEXT NULL,
|
||
"AccessTokenExpiresAt" TEXT NULL,
|
||
"Scope" TEXT NOT NULL,
|
||
"ConnectedAt" TEXT NOT NULL,
|
||
"LastSyncedAt" TEXT NULL,
|
||
"LastSyncAttemptedAt" TEXT NULL,
|
||
"LastSyncSucceededAt" TEXT NULL,
|
||
"LastSyncMode" TEXT NULL,
|
||
"LastSyncSource" TEXT NULL,
|
||
"LastSyncStatus" TEXT NULL,
|
||
"LastSyncError" TEXT NULL
|
||
);
|
||
""");
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "GmailReviewDecisions" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailReviewDecisions" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"ThreadId" TEXT NOT NULL,
|
||
"JobApplicationId" INTEGER NULL,
|
||
"Decision" TEXT NOT NULL,
|
||
"Note" TEXT NULL,
|
||
"UpdatedAt" TEXT NOT NULL
|
||
);
|
||
""");
|
||
|
||
EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT NULL;");
|
||
EnsureColumn(c, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSucceededAt TEXT NULL;");
|
||
EnsureColumn(c, "GmailConnections", "LastSyncMode", "ALTER TABLE GmailConnections ADD COLUMN LastSyncMode TEXT NULL;");
|
||
EnsureColumn(c, "GmailConnections", "LastSyncSource", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSource TEXT NULL;");
|
||
EnsureColumn(c, "GmailConnections", "LastSyncStatus", "ALTER TABLE GmailConnections ADD COLUMN LastSyncStatus TEXT NULL;");
|
||
EnsureColumn(c, "GmailConnections", "LastSyncError", "ALTER TABLE GmailConnections ADD COLUMN LastSyncError TEXT NULL;");
|
||
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");""");
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
|
||
}
|
||
|
||
static void EnsureMicrosoftGraphConnectionsTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "MicrosoftGraphConnections" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_MicrosoftGraphConnections" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"MailAddress" TEXT NOT NULL,
|
||
"EncryptedRefreshToken" TEXT NOT NULL,
|
||
"EncryptedAccessToken" TEXT NULL,
|
||
"AccessTokenExpiresAt" TEXT NULL,
|
||
"Scope" TEXT NOT NULL,
|
||
"ConnectedAt" TEXT NOT NULL,
|
||
"LastSyncedAt" TEXT NULL,
|
||
"LastSyncAttemptedAt" TEXT NULL,
|
||
"LastSyncSucceededAt" TEXT NULL,
|
||
"LastSyncMode" TEXT NULL,
|
||
"LastSyncSource" TEXT NULL,
|
||
"LastSyncStatus" TEXT NULL,
|
||
"LastSyncError" TEXT NULL
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId" ON "MicrosoftGraphConnections" ("OwnerUserId");""");
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress" ON "MicrosoftGraphConnections" ("OwnerUserId", "MailAddress");""");
|
||
}
|
||
|
||
static void EnsureImapConnectionsTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "ImapConnections" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_ImapConnections" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"Host" TEXT NOT NULL,
|
||
"Port" INTEGER NOT NULL,
|
||
"UseSsl" INTEGER NOT NULL,
|
||
"Username" TEXT NOT NULL,
|
||
"EncryptedPassword" TEXT NOT NULL,
|
||
"ConnectedAt" TEXT NOT NULL,
|
||
"LastSyncedAt" TEXT NULL,
|
||
"LastSyncAttemptedAt" TEXT NULL,
|
||
"LastSyncSucceededAt" TEXT NULL,
|
||
"LastSyncMode" TEXT NULL,
|
||
"LastSyncSource" TEXT NULL,
|
||
"LastSyncStatus" TEXT NULL,
|
||
"LastSyncError" TEXT NULL
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_ImapConnections_OwnerUserId" ON "ImapConnections" ("OwnerUserId");""");
|
||
}
|
||
|
||
static void EnsureCvTables(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CvUploadArtifacts" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvUploadArtifacts" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"OriginalFileName" TEXT NOT NULL,
|
||
"StoredFileName" TEXT NOT NULL,
|
||
"MimeType" TEXT NOT NULL,
|
||
"ByteSize" INTEGER NOT NULL,
|
||
"Sha256" TEXT NOT NULL,
|
||
"StoragePath" TEXT NOT NULL,
|
||
"UploadedAtUtc" TEXT NOT NULL
|
||
);
|
||
""");
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CvExtractionRuns" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvExtractionRuns" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"ArtifactId" INTEGER NULL,
|
||
"Trigger" TEXT NOT NULL,
|
||
"ParserVersion" TEXT NOT NULL,
|
||
"NormalizerVersion" TEXT NOT NULL,
|
||
"LlmPromptVersion" TEXT NOT NULL,
|
||
"Status" TEXT NOT NULL,
|
||
"RawExtractedText" TEXT NULL,
|
||
"NormalizedText" TEXT NULL,
|
||
"StructuredProfileJson" TEXT NULL,
|
||
"ErrorMessage" TEXT NULL,
|
||
"StartedAtUtc" TEXT NOT NULL,
|
||
"CompletedAtUtc" TEXT NULL,
|
||
"AppliedAtUtc" TEXT NULL,
|
||
CONSTRAINT "FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId" FOREIGN KEY ("ArtifactId") REFERENCES "CvUploadArtifacts" ("Id") ON DELETE SET NULL
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc" ON "CvUploadArtifacts" ("OwnerUserId", "UploadedAtUtc");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc" ON "CvExtractionRuns" ("OwnerUserId", "StartedAtUtc");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvExtractionRuns_ArtifactId" ON "CvExtractionRuns" ("ArtifactId");""");
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_TailoredCvDrafts" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"JobApplicationId" INTEGER NOT NULL,
|
||
"CanonicalProfileVersion" INTEGER NULL,
|
||
"TemplateId" TEXT NOT NULL,
|
||
"Headline" TEXT NULL,
|
||
"SummaryJson" TEXT NULL,
|
||
"SelectedSkillsJson" TEXT NULL,
|
||
"ExperienceJson" TEXT NULL,
|
||
"EducationJson" TEXT NULL,
|
||
"CustomSectionsJson" TEXT NULL,
|
||
"RenderOptionsJson" TEXT NULL,
|
||
"GenerationContextHash" TEXT NULL,
|
||
"LastGeneratedAtUtc" TEXT NULL,
|
||
"LastEditedAtUtc" TEXT NULL,
|
||
"Status" TEXT NOT NULL,
|
||
CONSTRAINT "FK_TailoredCvDrafts_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId" ON "TailoredCvDrafts" ("OwnerUserId", "JobApplicationId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TailoredCvDrafts_JobApplicationId" ON "TailoredCvDrafts" ("JobApplicationId");""");
|
||
}
|
||
|
||
static void EnsureTwoFactorRecoveryCodesTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "TwoFactorRecoveryCodes" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_TwoFactorRecoveryCodes" PRIMARY KEY AUTOINCREMENT,
|
||
"UserId" TEXT NOT NULL,
|
||
"CodeHash" TEXT NOT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
"UsedAtUtc" TEXT NULL
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc" ON "TwoFactorRecoveryCodes" ("UserId", "UsedAtUtc");""");
|
||
}
|
||
|
||
static void EnsureTrustedDevicesTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "TrustedDevices" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_TrustedDevices" PRIMARY KEY AUTOINCREMENT,
|
||
"UserId" TEXT NOT NULL,
|
||
"TokenHash" TEXT NOT NULL,
|
||
"DeviceLabel" TEXT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
"LastSeenAtUtc" TEXT NOT NULL,
|
||
"ExpiresAtUtc" TEXT NOT NULL
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_UserId" ON "TrustedDevices" ("UserId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_TrustedDevices_TokenHash" ON "TrustedDevices" ("TokenHash");""");
|
||
}
|
||
|
||
static void EnsureUserSessionsTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "UserSessions" (
|
||
"Id" TEXT NOT NULL CONSTRAINT "PK_UserSessions" PRIMARY KEY,
|
||
"UserId" TEXT NOT NULL,
|
||
"DeviceLabel" TEXT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
"LastSeenAtUtc" TEXT NOT NULL,
|
||
"ExpiresAtUtc" TEXT NOT NULL,
|
||
"RevokedAtUtc" TEXT NULL
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_UserSessions_UserId" ON "UserSessions" ("UserId");""");
|
||
}
|
||
|
||
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
|
||
// Phase F1). Additive tables: ApplicationUser.ProfileCvStructureJson remains the
|
||
// authoritative column every existing read path uses; these mirror it so future
|
||
// Career Workspace features (variants, history UI) have a real table to build on.
|
||
static void EnsureCareerProfileTables(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CareerProfiles" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfiles" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"ProfileJson" TEXT NOT NULL,
|
||
"LongTailJson" TEXT NOT NULL DEFAULT '',
|
||
"Version" INTEGER NOT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
"UpdatedAtUtc" TEXT NOT NULL
|
||
);
|
||
""");
|
||
|
||
// LongTailJson was added to the CareerProfile model (Phase 3) but this CREATE and
|
||
// the MySQL one were never updated to match, and no column-repair existed — so a
|
||
// CareerProfiles table created before this line lacks the column and /api/cv/outline
|
||
// (CareerProfileService.LoadStructuredAsync) fails with "Unknown column LongTailJson".
|
||
// Additive repair for existing databases.
|
||
EnsureColumn(c, "CareerProfiles", "LongTailJson", """ALTER TABLE "CareerProfiles" ADD COLUMN "LongTailJson" TEXT NOT NULL DEFAULT '';""");
|
||
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CareerProfileVersions" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProfileVersions" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"CareerProfileId" INTEGER NOT NULL,
|
||
"Version" INTEGER NOT NULL,
|
||
"ProfileJson" TEXT NOT NULL,
|
||
"Source" TEXT NOT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
CONSTRAINT "FK_CareerProfileVersions_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CareerProfiles_OwnerUserId" ON "CareerProfiles" ("OwnerUserId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version" ON "CareerProfileVersions" ("OwnerUserId", "CareerProfileId", "Version");""");
|
||
|
||
// The six relational children of CareerProfile. Reconciler-owned: their migration
|
||
// is a no-op because it was scaffolded against SQLite and produced an unindexable
|
||
// longtext OwnerUserId on MariaDB. docs/infrastructure/database-ownership.md.
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CareerExperiences" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerExperiences" PRIMARY KEY AUTOINCREMENT,
|
||
"Title" TEXT NULL,
|
||
"Company" TEXT NULL,
|
||
"Location" TEXT NULL,
|
||
"Start" TEXT NULL,
|
||
"End" TEXT NULL,
|
||
"StartDate" TEXT NULL,
|
||
"EndDate" TEXT NULL,
|
||
"IsCurrent" INTEGER NOT NULL DEFAULT 0,
|
||
"BulletsJson" TEXT NOT NULL DEFAULT '[]',
|
||
"SkillsJson" TEXT NOT NULL DEFAULT '[]',
|
||
"CareerProfileId" INTEGER NOT NULL,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"ItemKey" TEXT NOT NULL,
|
||
"SortOrder" INTEGER NOT NULL,
|
||
CONSTRAINT "FK_CareerExperiences_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_CareerProfileId" ON "CareerExperiences" ("CareerProfileId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder" ON "CareerExperiences" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CareerEducations" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerEducations" PRIMARY KEY AUTOINCREMENT,
|
||
"Qualification" TEXT NULL,
|
||
"QualificationLevel" TEXT NULL,
|
||
"Institution" TEXT NULL,
|
||
"Location" TEXT NULL,
|
||
"Start" TEXT NULL,
|
||
"End" TEXT NULL,
|
||
"StartDate" TEXT NULL,
|
||
"EndDate" TEXT NULL,
|
||
"DetailsJson" TEXT NOT NULL DEFAULT '[]',
|
||
"CareerProfileId" INTEGER NOT NULL,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"ItemKey" TEXT NOT NULL,
|
||
"SortOrder" INTEGER NOT NULL,
|
||
CONSTRAINT "FK_CareerEducations_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerEducations_CareerProfileId" ON "CareerEducations" ("CareerProfileId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder" ON "CareerEducations" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CareerSkills" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerSkills" PRIMARY KEY AUTOINCREMENT,
|
||
"Name" TEXT NULL,
|
||
"Category" TEXT NULL,
|
||
"Proficiency" TEXT NULL,
|
||
"CareerProfileId" INTEGER NOT NULL,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"ItemKey" TEXT NOT NULL,
|
||
"SortOrder" INTEGER NOT NULL,
|
||
CONSTRAINT "FK_CareerSkills_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerSkills_CareerProfileId" ON "CareerSkills" ("CareerProfileId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder" ON "CareerSkills" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CareerProjects" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerProjects" PRIMARY KEY AUTOINCREMENT,
|
||
"Name" TEXT NULL,
|
||
"Role" TEXT NULL,
|
||
"Location" TEXT NULL,
|
||
"Start" TEXT NULL,
|
||
"End" TEXT NULL,
|
||
"StartDate" TEXT NULL,
|
||
"EndDate" TEXT NULL,
|
||
"BulletsJson" TEXT NOT NULL DEFAULT '[]',
|
||
"SkillsJson" TEXT NOT NULL DEFAULT '[]',
|
||
"LinksJson" TEXT NOT NULL DEFAULT '[]',
|
||
"CareerProfileId" INTEGER NOT NULL,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"ItemKey" TEXT NOT NULL,
|
||
"SortOrder" INTEGER NOT NULL,
|
||
CONSTRAINT "FK_CareerProjects_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProjects_CareerProfileId" ON "CareerProjects" ("CareerProfileId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder" ON "CareerProjects" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CareerCertifications" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerCertifications" PRIMARY KEY AUTOINCREMENT,
|
||
"Name" TEXT NULL,
|
||
"Issuer" TEXT NULL,
|
||
"Location" TEXT NULL,
|
||
"Date" TEXT NULL,
|
||
"DateNormalized" TEXT NULL,
|
||
"DetailsJson" TEXT NOT NULL DEFAULT '[]',
|
||
"CareerProfileId" INTEGER NOT NULL,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"ItemKey" TEXT NOT NULL,
|
||
"SortOrder" INTEGER NOT NULL,
|
||
CONSTRAINT "FK_CareerCertifications_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_CareerProfileId" ON "CareerCertifications" ("CareerProfileId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder" ON "CareerCertifications" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CareerLanguages" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CareerLanguages" PRIMARY KEY AUTOINCREMENT,
|
||
"Name" TEXT NULL,
|
||
"Level" TEXT NULL,
|
||
"Notes" TEXT NULL,
|
||
"CareerProfileId" INTEGER NOT NULL,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"ItemKey" TEXT NOT NULL,
|
||
"SortOrder" INTEGER NOT NULL,
|
||
CONSTRAINT "FK_CareerLanguages_CareerProfiles_CareerProfileId" FOREIGN KEY ("CareerProfileId") REFERENCES "CareerProfiles" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_CareerProfileId" ON "CareerLanguages" ("CareerProfileId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder" ON "CareerLanguages" ("OwnerUserId", "CareerProfileId", "SortOrder");""");
|
||
}
|
||
|
||
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5):
|
||
// stop re-running the AI call on every tab open by persisting the last generated
|
||
// note per job, keyed by the attachment selection it was generated from.
|
||
static void EnsureInterviewPrepNotesTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "InterviewPrepNotes" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepNotes" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"JobApplicationId" INTEGER NOT NULL,
|
||
"AttachmentContextSignature" TEXT NOT NULL,
|
||
"Summary" TEXT NOT NULL,
|
||
"TalkingPointsJson" TEXT NOT NULL,
|
||
"LikelyQuestionsJson" TEXT NOT NULL,
|
||
"WeakSpotsJson" TEXT NOT NULL,
|
||
"GeneratedAtUtc" TEXT NOT NULL,
|
||
CONSTRAINT "FK_InterviewPrepNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId" ON "InterviewPrepNotes" ("OwnerUserId", "JobApplicationId");""");
|
||
}
|
||
|
||
// Generic AI workspace note persistence (candidate fit, focus plan) -- same
|
||
// rationale as EnsureInterviewPrepNotesTable, generalized for DTOs too irregular
|
||
// for per-field columns.
|
||
static void EnsureAiWorkspaceNotesTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "AiWorkspaceNotes" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_AiWorkspaceNotes" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"JobApplicationId" INTEGER NOT NULL,
|
||
"NoteType" TEXT NOT NULL,
|
||
"AttachmentContextSignature" TEXT NOT NULL,
|
||
"ResultJson" TEXT NOT NULL,
|
||
"GeneratedAtUtc" TEXT NOT NULL,
|
||
CONSTRAINT "FK_AiWorkspaceNotes_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType" ON "AiWorkspaceNotes" ("OwnerUserId", "JobApplicationId", "NoteType");""");
|
||
}
|
||
|
||
// Phase 4 CV Builder: a variant is a lens over the master profile; versions are its
|
||
// autosave history. Reconciler-owned (not migration-owned) so the schema is MySQL-safe
|
||
// on prod -- see the AddCvVariants migration note. docs/architecture/cv-builder.md.
|
||
static void EnsureCvBuilderTables(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CvVariants" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvVariants" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"PublicSlug" TEXT NOT NULL,
|
||
"Name" TEXT NOT NULL,
|
||
"JobApplicationId" INTEGER NULL,
|
||
"SettingsJson" TEXT NOT NULL,
|
||
"IsPublic" INTEGER NOT NULL,
|
||
"Version" INTEGER NOT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
"UpdatedAtUtc" TEXT NOT NULL,
|
||
CONSTRAINT "FK_CvVariants_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE SET NULL
|
||
);
|
||
""");
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CvVariantVersions" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CvVariantVersions" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"CvVariantId" INTEGER NOT NULL,
|
||
"Version" INTEGER NOT NULL,
|
||
"SettingsJson" TEXT NOT NULL,
|
||
"Source" TEXT NOT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
CONSTRAINT "FK_CvVariantVersions_CvVariants_CvVariantId" FOREIGN KEY ("CvVariantId") REFERENCES "CvVariants" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariants_JobApplicationId" ON "CvVariants" ("JobApplicationId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariants_OwnerUserId_UpdatedAtUtc" ON "CvVariants" ("OwnerUserId", "UpdatedAtUtc");""");
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_CvVariants_PublicSlug" ON "CvVariants" ("PublicSlug");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CvVariantVersions_CvVariantId_Version" ON "CvVariantVersions" ("CvVariantId", "Version");""");
|
||
}
|
||
|
||
// Phase 5 AI Workspace: append-only AI interaction history per job application.
|
||
static void EnsureAiInteractionsTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "AiInteractions" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_AiInteractions" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"JobApplicationId" INTEGER NOT NULL,
|
||
"Module" TEXT NOT NULL,
|
||
"Mode" TEXT NULL,
|
||
"Title" TEXT NOT NULL,
|
||
"Provider" TEXT NOT NULL,
|
||
"ResultJson" TEXT NOT NULL,
|
||
"InputCharacterCount" INTEGER NOT NULL DEFAULT 0,
|
||
"OutputCharacterCount" INTEGER NOT NULL DEFAULT 0,
|
||
"EstimatedTokenCount" INTEGER NOT NULL DEFAULT 0,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
CONSTRAINT "FK_AiInteractions_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
EnsureColumn(c, "AiInteractions", "InputCharacterCount", "ALTER TABLE AiInteractions ADD COLUMN InputCharacterCount INTEGER NOT NULL DEFAULT 0;");
|
||
EnsureColumn(c, "AiInteractions", "OutputCharacterCount", "ALTER TABLE AiInteractions ADD COLUMN OutputCharacterCount INTEGER NOT NULL DEFAULT 0;");
|
||
EnsureColumn(c, "AiInteractions", "EstimatedTokenCount", "ALTER TABLE AiInteractions ADD COLUMN EstimatedTokenCount INTEGER NOT NULL DEFAULT 0;");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AiInteractions_JobApplicationId" ON "AiInteractions" ("JobApplicationId");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_AiInteractions_Owner_Job_Module_Created" ON "AiInteractions" ("OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc");""");
|
||
}
|
||
|
||
// Phase 5 Milestone 2: the application checklist (workflow guidance over readiness signals).
|
||
static void EnsureApplicationChecklistTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "ApplicationChecklistItems" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_ApplicationChecklistItems" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"JobApplicationId" INTEGER NOT NULL,
|
||
"SystemKey" TEXT NULL,
|
||
"AutoSignal" TEXT NULL,
|
||
"Title" TEXT NOT NULL,
|
||
"Description" TEXT NULL,
|
||
"Category" TEXT NOT NULL,
|
||
"Status" TEXT NOT NULL,
|
||
"Section" TEXT NULL,
|
||
"SortOrder" INTEGER NOT NULL,
|
||
"IsSystemGenerated" INTEGER NOT NULL,
|
||
"IsAutoCompleted" INTEGER NOT NULL,
|
||
"CompletedAt" TEXT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
"UpdatedAtUtc" TEXT NOT NULL,
|
||
CONSTRAINT "FK_ApplicationChecklistItems_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_ApplicationChecklistItems_JobApplicationId_SystemKey" ON "ApplicationChecklistItems" ("JobApplicationId", "SystemKey");""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_ApplicationChecklistItems_Owner_Job_Sort" ON "ApplicationChecklistItems" ("OwnerUserId", "JobApplicationId", "SortOrder");""");
|
||
}
|
||
|
||
// Phase 5.4: append-only cover letter history.
|
||
static void EnsureCoverLetterVersionsTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "CoverLetterVersions" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_CoverLetterVersions" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"JobApplicationId" INTEGER NOT NULL,
|
||
"Version" INTEGER NOT NULL,
|
||
"Text" TEXT NOT NULL,
|
||
"Source" TEXT NOT NULL,
|
||
"AiAction" TEXT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
CONSTRAINT "FK_CoverLetterVersions_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_CoverLetterVersions_Owner_Job_Version" ON "CoverLetterVersions" ("OwnerUserId", "JobApplicationId", "Version");""");
|
||
}
|
||
|
||
// Phase 5.5: user-owned interview preparation (not the AI cache InterviewPrepNote).
|
||
static void EnsureInterviewPrepItemsTable(DbConnection c)
|
||
{
|
||
Exec(c, """
|
||
CREATE TABLE IF NOT EXISTS "InterviewPrepItems" (
|
||
"Id" INTEGER NOT NULL CONSTRAINT "PK_InterviewPrepItems" PRIMARY KEY AUTOINCREMENT,
|
||
"OwnerUserId" TEXT NOT NULL,
|
||
"JobApplicationId" INTEGER NOT NULL,
|
||
"Category" TEXT NOT NULL,
|
||
"Title" TEXT NOT NULL,
|
||
"Content" TEXT NULL,
|
||
"Source" TEXT NOT NULL,
|
||
"IsPrepared" INTEGER NOT NULL,
|
||
"SortOrder" INTEGER NOT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
"UpdatedAtUtc" TEXT NOT NULL,
|
||
CONSTRAINT "FK_InterviewPrepItems_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_InterviewPrepItems_Owner_Job_Sort" ON "InterviewPrepItems" ("OwnerUserId", "JobApplicationId", "SortOrder");""");
|
||
}
|
||
|
||
EnsureGmailConnectionsTable(conn);
|
||
EnsureMicrosoftGraphConnectionsTable(conn);
|
||
EnsureImapConnectionsTable(conn);
|
||
EnsureCvTables(conn);
|
||
EnsureTwoFactorRecoveryCodesTable(conn);
|
||
EnsureTrustedDevicesTable(conn);
|
||
EnsureUserSessionsTable(conn);
|
||
EnsureCareerProfileTables(conn);
|
||
EnsureInterviewPrepNotesTable(conn);
|
||
EnsureAiWorkspaceNotesTable(conn);
|
||
EnsureCvBuilderTables(conn);
|
||
EnsureAiInteractionsTable(conn);
|
||
EnsureApplicationChecklistTable(conn);
|
||
EnsureCoverLetterVersionsTable(conn);
|
||
EnsureInterviewPrepItemsTable(conn);
|
||
|
||
// Once the base app tables exist, provision this reconciler-owned schema set and
|
||
// stamp its historical migration before later migrations rebuild JobApplications.
|
||
var isLegacy =
|
||
HasMigration(conn, "20260310174114_AddCorrespondence") &&
|
||
!HasMigration(conn, legacyMigrationId);
|
||
|
||
if (isLegacy)
|
||
{
|
||
EnsureColumn(conn, "Companies", "Source", "ALTER TABLE Companies ADD COLUMN Source TEXT NULL;");
|
||
EnsureColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE JobApplications ADD COLUMN IsDeleted INTEGER NOT NULL DEFAULT 0;");
|
||
EnsureColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE JobApplications ADD COLUMN DeletedAt TEXT NULL;");
|
||
EnsureColumn(conn, "JobApplications", "Location", "ALTER TABLE JobApplications ADD COLUMN Location TEXT NULL;");
|
||
EnsureColumn(conn, "JobApplications", "Salary", "ALTER TABLE JobApplications ADD COLUMN Salary TEXT NULL;");
|
||
EnsureColumn(conn, "JobApplications", "NextAction", "ALTER TABLE JobApplications ADD COLUMN NextAction TEXT NULL;");
|
||
EnsureColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE JobApplications ADD COLUMN FollowUpAt TEXT NULL;");
|
||
|
||
// Ensure the persisted short summary column exists for older dev DBs.
|
||
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
|
||
|
||
// Multi-user support: scope data to the authenticated user.
|
||
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
|
||
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
|
||
|
||
// Legacy DBs may be missing later correspondence columns (Subject/Channel).
|
||
if (HasTable(conn, "Correspondences"))
|
||
{
|
||
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
|
||
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
|
||
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
|
||
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
||
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
||
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
|
||
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
|
||
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
|
||
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
|
||
}
|
||
|
||
// Record the migration as applied.
|
||
Exec(
|
||
conn,
|
||
"INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) " +
|
||
$"VALUES ('{legacyMigrationId}', '{legacyProductVersion}');"
|
||
);
|
||
}
|
||
|
||
// Some dev DBs may not match the "legacy" fingerprint above but still lack
|
||
// later ad-hoc columns. Ensure them unconditionally if missing (also re-run once
|
||
// more after Migrate() below, in case this is a brand-new DB where these tables
|
||
// didn't exist yet at this point).
|
||
ReconcileCoreAppColumns(conn);
|
||
|
||
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
|
||
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). Guarded
|
||
// on table existence: on a brand-new DB the table is created by Migrate()
|
||
// below, so the index is picked up on the next start.
|
||
if (HasTable(conn, "JobApplications"))
|
||
{
|
||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted" ON "JobApplications" ("OwnerUserId", "IsDeleted");""");
|
||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_FollowUpAt" ON "JobApplications" ("OwnerUserId", "FollowUpAt");""");
|
||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobApplications_OwnerUserId_IsDeleted_Status" ON "JobApplications" ("OwnerUserId", "IsDeleted", "Status");""");
|
||
}
|
||
|
||
if (HasTable(conn, "Correspondences"))
|
||
{
|
||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_Correspondences_JobApplicationId" ON "Correspondences" ("JobApplicationId");""");
|
||
}
|
||
|
||
if (HasTable(conn, "JobEvents"))
|
||
{
|
||
Exec(conn, """CREATE INDEX IF NOT EXISTS "IX_JobEvents_JobApplicationId" ON "JobEvents" ("JobApplicationId");""");
|
||
}
|
||
|
||
// Ensure data folder exists before creating/opening SQLite files.
|
||
Directory.CreateDirectory(paths.DataRoot);
|
||
}
|
||
else
|
||
{
|
||
var cs = app.Configuration.GetConnectionString("JobTracker");
|
||
if (!string.IsNullOrWhiteSpace(cs))
|
||
{
|
||
// Not a `using`: the connection belongs to the DbContext, and ReconcileSchema runs more
|
||
// than once — disposing it here made the second pass throw ObjectDisposedException.
|
||
DbConnection conn = db.Database.GetDbConnection();
|
||
// ReconcileSchema runs twice; the second pass may inherit an already-open connection.
|
||
if (conn.State != System.Data.ConnectionState.Open) conn.Open();
|
||
EnsureIdentityTablesMySql(conn);
|
||
|
||
|
||
static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column)
|
||
{
|
||
using var cmd = c.CreateCommand();
|
||
cmd.CommandText = @"SELECT EXTRA
|
||
FROM INFORMATION_SCHEMA.COLUMNS
|
||
WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column
|
||
LIMIT 1;";
|
||
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
|
||
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
|
||
var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
|
||
var extra = cmd.ExecuteScalar()?.ToString() ?? string.Empty;
|
||
return extra.Contains("auto_increment", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
static void EnsureMySqlAutoIncrementPrimaryKey(DbConnection c, string table, string column)
|
||
{
|
||
if (!HasMySqlTable(c, table) || !MySqlColumnExists(c, table, column) || MySqlIntPrimaryKeyIsAutoIncrement(c, table, column))
|
||
{
|
||
return;
|
||
}
|
||
|
||
using var cmd = c.CreateCommand();
|
||
cmd.CommandText = $"ALTER TABLE `{table}` MODIFY COLUMN `{column}` int NOT NULL AUTO_INCREMENT;";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "Companies", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "JobApplications", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "Correspondences", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "Attachments", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "ImapConnections", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfiles", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProfileVersions", "Id");
|
||
|
||
EnsureMySqlIndex(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId", "`OwnerUserId`", unique: true);
|
||
|
||
EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`");
|
||
|
||
// The six relational children of CareerProfile — reconciler-owned (their
|
||
// migration is a no-op). varchar OwnerUserId/ItemKey so the composite index fits
|
||
// inside MariaDB's key limit. docs/infrastructure/database-ownership.md.
|
||
if (!HasMySqlTable(conn, "CareerExperiences") && HasMySqlTable(conn, "CareerProfiles"))
|
||
{
|
||
using var cmdCareerExperiences = conn.CreateCommand();
|
||
cmdCareerExperiences.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerExperiences` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`Title` longtext NULL,
|
||
`Company` longtext NULL,
|
||
`Location` longtext NULL,
|
||
`Start` longtext NULL,
|
||
`End` longtext NULL,
|
||
`StartDate` longtext NULL,
|
||
`EndDate` longtext NULL,
|
||
`IsCurrent` tinyint(1) NOT NULL DEFAULT 0,
|
||
`BulletsJson` longtext NOT NULL,
|
||
`SkillsJson` longtext NOT NULL,
|
||
`CareerProfileId` int NOT NULL,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`ItemKey` varchar(255) NOT NULL,
|
||
`SortOrder` int NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CareerExperiences_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmdCareerExperiences.ExecuteNonQuery();
|
||
}
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerExperiences", "Id");
|
||
EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_CareerProfileId", "`CareerProfileId`");
|
||
EnsureMySqlIndex(conn, "CareerExperiences", "IX_CareerExperiences_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
|
||
|
||
if (!HasMySqlTable(conn, "CareerEducations") && HasMySqlTable(conn, "CareerProfiles"))
|
||
{
|
||
using var cmdCareerEducations = conn.CreateCommand();
|
||
cmdCareerEducations.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerEducations` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`Qualification` longtext NULL,
|
||
`QualificationLevel` longtext NULL,
|
||
`Institution` longtext NULL,
|
||
`Location` longtext NULL,
|
||
`Start` longtext NULL,
|
||
`End` longtext NULL,
|
||
`StartDate` longtext NULL,
|
||
`EndDate` longtext NULL,
|
||
`DetailsJson` longtext NOT NULL,
|
||
`CareerProfileId` int NOT NULL,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`ItemKey` varchar(255) NOT NULL,
|
||
`SortOrder` int NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CareerEducations_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmdCareerEducations.ExecuteNonQuery();
|
||
}
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerEducations", "Id");
|
||
EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_CareerProfileId", "`CareerProfileId`");
|
||
EnsureMySqlIndex(conn, "CareerEducations", "IX_CareerEducations_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
|
||
|
||
if (!HasMySqlTable(conn, "CareerSkills") && HasMySqlTable(conn, "CareerProfiles"))
|
||
{
|
||
using var cmdCareerSkills = conn.CreateCommand();
|
||
cmdCareerSkills.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerSkills` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`Name` longtext NULL,
|
||
`Category` longtext NULL,
|
||
`Proficiency` longtext NULL,
|
||
`CareerProfileId` int NOT NULL,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`ItemKey` varchar(255) NOT NULL,
|
||
`SortOrder` int NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CareerSkills_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmdCareerSkills.ExecuteNonQuery();
|
||
}
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerSkills", "Id");
|
||
EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_CareerProfileId", "`CareerProfileId`");
|
||
EnsureMySqlIndex(conn, "CareerSkills", "IX_CareerSkills_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
|
||
|
||
if (!HasMySqlTable(conn, "CareerProjects") && HasMySqlTable(conn, "CareerProfiles"))
|
||
{
|
||
using var cmdCareerProjects = conn.CreateCommand();
|
||
cmdCareerProjects.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProjects` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`Name` longtext NULL,
|
||
`Role` longtext NULL,
|
||
`Location` longtext NULL,
|
||
`Start` longtext NULL,
|
||
`End` longtext NULL,
|
||
`StartDate` longtext NULL,
|
||
`EndDate` longtext NULL,
|
||
`BulletsJson` longtext NOT NULL,
|
||
`SkillsJson` longtext NOT NULL,
|
||
`LinksJson` longtext NOT NULL,
|
||
`CareerProfileId` int NOT NULL,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`ItemKey` varchar(255) NOT NULL,
|
||
`SortOrder` int NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CareerProjects_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmdCareerProjects.ExecuteNonQuery();
|
||
}
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerProjects", "Id");
|
||
EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_CareerProfileId", "`CareerProfileId`");
|
||
EnsureMySqlIndex(conn, "CareerProjects", "IX_CareerProjects_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
|
||
|
||
if (!HasMySqlTable(conn, "CareerCertifications") && HasMySqlTable(conn, "CareerProfiles"))
|
||
{
|
||
using var cmdCareerCertifications = conn.CreateCommand();
|
||
cmdCareerCertifications.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerCertifications` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`Name` longtext NULL,
|
||
`Issuer` longtext NULL,
|
||
`Location` longtext NULL,
|
||
`Date` longtext NULL,
|
||
`DateNormalized` longtext NULL,
|
||
`DetailsJson` longtext NOT NULL,
|
||
`CareerProfileId` int NOT NULL,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`ItemKey` varchar(255) NOT NULL,
|
||
`SortOrder` int NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CareerCertifications_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmdCareerCertifications.ExecuteNonQuery();
|
||
}
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerCertifications", "Id");
|
||
EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_CareerProfileId", "`CareerProfileId`");
|
||
EnsureMySqlIndex(conn, "CareerCertifications", "IX_CareerCertifications_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
|
||
|
||
if (!HasMySqlTable(conn, "CareerLanguages") && HasMySqlTable(conn, "CareerProfiles"))
|
||
{
|
||
using var cmdCareerLanguages = conn.CreateCommand();
|
||
cmdCareerLanguages.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerLanguages` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`Name` longtext NULL,
|
||
`Level` longtext NULL,
|
||
`Notes` longtext NULL,
|
||
`CareerProfileId` int NOT NULL,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`ItemKey` varchar(255) NOT NULL,
|
||
`SortOrder` int NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CareerLanguages_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmdCareerLanguages.ExecuteNonQuery();
|
||
}
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CareerLanguages", "Id");
|
||
EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_CareerProfileId", "`CareerProfileId`");
|
||
EnsureMySqlIndex(conn, "CareerLanguages", "IX_CareerLanguages_OwnerUserId_CareerProfileId_SortOrder", "`OwnerUserId`, `CareerProfileId`, `SortOrder`");
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id");
|
||
|
||
EnsureMySqlIndex(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId", "`OwnerUserId`, `JobApplicationId`", unique: true);
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id");
|
||
|
||
EnsureMySqlIndex(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType", "`OwnerUserId`, `JobApplicationId`, `NoteType`", unique: true);
|
||
|
||
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
|
||
// Correspondences/Attachments) -- re-run once more after Migrate() below via
|
||
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
|
||
ReconcileCoreAppColumnsMySql(conn);
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvExtractionRunId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvExtractionRunId` int NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvProfileVersion", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvProfileVersion` int NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "AvatarImageDataUrl", "ALTER TABLE `AspNetUsers` ADD COLUMN `AvatarImageDataUrl` longtext NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleSubject` longtext NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleEmail` longtext NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `GoogleLinkedAt` datetime NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftSubject", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftSubject` longtext NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftEmail", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftEmail` longtext NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "MicrosoftLinkedAt", "ALTER TABLE `AspNetUsers` ADD COLUMN `MicrosoftLinkedAt` datetime NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpSecretEncrypted` longtext NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpPendingSecretEncrypted` longtext NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpEnabledAtUtc` datetime NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "StripeCustomerId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeCustomerId` varchar(255) NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionId` varchar(255) NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionStatus` varchar(64) NULL;");
|
||
EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
|
||
|
||
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
|
||
// used to create it too, which made a clean install fail with "Table 'RuleSettings'
|
||
// already exists" when Migrate() then tried. Only the default row is seeded here,
|
||
// and only once the table exists (pass 3 on a fresh database).
|
||
if (HasMySqlTable(conn, "RuleSettings"))
|
||
{
|
||
using var seedRuleSettings = conn.CreateCommand();
|
||
seedRuleSettings.CommandText = @"INSERT INTO `RuleSettings` (`Id`, `AppliedFollowUpDays`, `AppliedGhostDays`, `OfferFollowUpDays`, `OfferGhostDays`, `FeedbackFollowUpDays`, `FeedbackGhostDays`)
|
||
SELECT 1, 14, 30, 7, 14, 7, 14
|
||
WHERE NOT EXISTS (SELECT 1 FROM `RuleSettings` WHERE `Id` = 1);";
|
||
seedRuleSettings.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "UserRuleSettings"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserRuleSettings` (
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`AppliedFollowUpDays` int NOT NULL,
|
||
`AppliedGhostDays` int NOT NULL,
|
||
`OfferFollowUpDays` int NOT NULL,
|
||
`OfferGhostDays` int NOT NULL,
|
||
`FeedbackFollowUpDays` int NOT NULL,
|
||
`FeedbackGhostDays` int NOT NULL,
|
||
PRIMARY KEY (`OwnerUserId`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "SystemEmailSettings"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `SystemEmailSettings` (
|
||
`Id` int NOT NULL,
|
||
`Enabled` tinyint(1) NULL,
|
||
`SmtpHost` longtext NULL,
|
||
`SmtpPort` int NULL,
|
||
`SmtpUser` longtext NULL,
|
||
`SmtpPassword` longtext NULL,
|
||
`From` longtext NULL,
|
||
`FromName` longtext NULL,
|
||
`SmtpEnableSsl` tinyint(1) NULL,
|
||
`SmtpTimeoutMs` int NULL,
|
||
PRIMARY KEY (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "CvUploadArtifacts"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvUploadArtifacts` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`OriginalFileName` longtext NOT NULL,
|
||
`StoredFileName` longtext NOT NULL,
|
||
`MimeType` longtext NOT NULL,
|
||
`ByteSize` bigint NOT NULL,
|
||
`Sha256` longtext NOT NULL,
|
||
`StoragePath` longtext NOT NULL,
|
||
`UploadedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "CvExtractionRuns") && HasMySqlTable(conn, "CvUploadArtifacts"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvExtractionRuns` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`ArtifactId` int NULL,
|
||
`Trigger` longtext NOT NULL,
|
||
`ParserVersion` longtext NOT NULL,
|
||
`NormalizerVersion` longtext NOT NULL,
|
||
`LlmPromptVersion` longtext NOT NULL,
|
||
`Status` longtext NOT NULL,
|
||
`RawExtractedText` longtext NULL,
|
||
`NormalizedText` longtext NULL,
|
||
`StructuredProfileJson` longtext NULL,
|
||
`ErrorMessage` longtext NULL,
|
||
`StartedAtUtc` datetime(6) NOT NULL,
|
||
`CompletedAtUtc` datetime(6) NULL,
|
||
`AppliedAtUtc` datetime(6) NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`ArtifactId`) REFERENCES `CvUploadArtifacts` (`Id`) ON DELETE SET NULL
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "GmailConnections"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `GmailConnections` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`GmailAddress` varchar(512) NOT NULL,
|
||
`EncryptedRefreshToken` longtext NOT NULL,
|
||
`EncryptedAccessToken` longtext NULL,
|
||
`AccessTokenExpiresAt` datetime(6) NULL,
|
||
`Scope` longtext NOT NULL,
|
||
`ConnectedAt` datetime(6) NOT NULL,
|
||
`LastSyncedAt` datetime(6) NULL,
|
||
`LastSyncAttemptedAt` datetime(6) NULL,
|
||
`LastSyncSucceededAt` datetime(6) NULL,
|
||
`LastSyncMode` varchar(255) NULL,
|
||
`LastSyncSource` varchar(255) NULL,
|
||
`LastSyncStatus` varchar(255) NULL,
|
||
`LastSyncError` longtext NULL,
|
||
PRIMARY KEY (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncAttemptedAt` datetime(6) NULL;");
|
||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSucceededAt` datetime(6) NULL;");
|
||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncMode", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncMode` varchar(255) NULL;");
|
||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSource", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSource` varchar(255) NULL;");
|
||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;");
|
||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;");
|
||
|
||
if (!HasMySqlTable(conn, "MicrosoftGraphConnections"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `MicrosoftGraphConnections` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`MailAddress` varchar(512) NOT NULL,
|
||
`EncryptedRefreshToken` longtext NOT NULL,
|
||
`EncryptedAccessToken` longtext NULL,
|
||
`AccessTokenExpiresAt` datetime(6) NULL,
|
||
`Scope` longtext NOT NULL,
|
||
`ConnectedAt` datetime(6) NOT NULL,
|
||
`LastSyncedAt` datetime(6) NULL,
|
||
`LastSyncAttemptedAt` datetime(6) NULL,
|
||
`LastSyncSucceededAt` datetime(6) NULL,
|
||
`LastSyncMode` varchar(255) NULL,
|
||
`LastSyncSource` varchar(255) NULL,
|
||
`LastSyncStatus` varchar(255) NULL,
|
||
`LastSyncError` longtext NULL,
|
||
PRIMARY KEY (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "ImapConnections"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ImapConnections` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`Host` varchar(255) NOT NULL,
|
||
`Port` int NOT NULL,
|
||
`UseSsl` tinyint(1) NOT NULL,
|
||
`Username` varchar(255) NOT NULL,
|
||
`EncryptedPassword` longtext NOT NULL,
|
||
`ConnectedAt` datetime(6) NOT NULL,
|
||
`LastSyncedAt` datetime(6) NULL,
|
||
`LastSyncAttemptedAt` datetime(6) NULL,
|
||
`LastSyncSucceededAt` datetime(6) NULL,
|
||
`LastSyncMode` varchar(255) NULL,
|
||
`LastSyncSource` varchar(255) NULL,
|
||
`LastSyncStatus` varchar(255) NULL,
|
||
`LastSyncError` longtext NULL,
|
||
PRIMARY KEY (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "TailoredCvDrafts") && HasMySqlTable(conn, "JobApplications"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TailoredCvDrafts` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`JobApplicationId` int NOT NULL,
|
||
`CanonicalProfileVersion` int NULL,
|
||
`TemplateId` varchar(100) NOT NULL,
|
||
`Headline` longtext NULL,
|
||
`SummaryJson` longtext NULL,
|
||
`SelectedSkillsJson` longtext NULL,
|
||
`ExperienceJson` longtext NULL,
|
||
`EducationJson` longtext NULL,
|
||
`CustomSectionsJson` longtext NULL,
|
||
`RenderOptionsJson` longtext NULL,
|
||
`GenerationContextHash` longtext NULL,
|
||
`LastGeneratedAtUtc` datetime(6) NULL,
|
||
`LastEditedAtUtc` datetime(6) NULL,
|
||
`Status` varchar(100) NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
// Career Workspace foundation (docs/career-workspace-implementation-roadmap.md
|
||
// Phase F1). Additive: AspNetUsers.ProfileCvStructureJson stays authoritative
|
||
// for every existing read path during the dual-write window.
|
||
if (!HasMySqlTable(conn, "CareerProfiles"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfiles` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`ProfileJson` longtext NOT NULL,
|
||
`LongTailJson` longtext NOT NULL,
|
||
`Version` int NOT NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
// Additive repair for a CareerProfiles table created before LongTailJson was added
|
||
// to the model. Without it, /api/cv/outline fails with "Unknown column LongTailJson".
|
||
// DEFAULT '' backfills existing rows and matches the non-nullable model property.
|
||
EnsureMySqlColumn(conn, "CareerProfiles", "LongTailJson", "ALTER TABLE `CareerProfiles` ADD COLUMN `LongTailJson` longtext NOT NULL DEFAULT '';");
|
||
|
||
if (!HasMySqlTable(conn, "CareerProfileVersions") && HasMySqlTable(conn, "CareerProfiles"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CareerProfileVersions` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`CareerProfileId` int NOT NULL,
|
||
`Version` int NOT NULL,
|
||
`ProfileJson` longtext NOT NULL,
|
||
`Source` varchar(100) NOT NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CareerProfileVersions_CareerProfiles_CareerProfileId` FOREIGN KEY (`CareerProfileId`) REFERENCES `CareerProfiles` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
|
||
if (!HasMySqlTable(conn, "InterviewPrepNotes") && HasMySqlTable(conn, "JobApplications"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepNotes` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`JobApplicationId` int NOT NULL,
|
||
`AttachmentContextSignature` longtext NOT NULL,
|
||
`Summary` longtext NOT NULL,
|
||
`TalkingPointsJson` longtext NOT NULL,
|
||
`LikelyQuestionsJson` longtext NOT NULL,
|
||
`WeakSpotsJson` longtext NOT NULL,
|
||
`GeneratedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_InterviewPrepNotes_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
// Generic AI workspace note persistence (candidate fit, focus plan).
|
||
if (!HasMySqlTable(conn, "AiWorkspaceNotes") && HasMySqlTable(conn, "JobApplications"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiWorkspaceNotes` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`JobApplicationId` int NOT NULL,
|
||
`NoteType` varchar(50) NOT NULL,
|
||
`AttachmentContextSignature` longtext NOT NULL,
|
||
`ResultJson` longtext NOT NULL,
|
||
`GeneratedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_AiWorkspaceNotes_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
// Phase 4 CV Builder + Phase 5 AI Workspace. Reconciler-owned rather than
|
||
// migration-owned: those migrations were scaffolded against SQLite, so on MariaDB
|
||
// they emit TEXT datetimes and a PK with no AUTO_INCREMENT, and the composite
|
||
// index over a TEXT column then exceeds MySQL's 3072-byte key limit -- which
|
||
// crashed backend startup on prod. Drop any such half-built table (only when it
|
||
// holds no rows) and rebuild from the correct MySQL DDL below.
|
||
// Children first: CvVariantVersions FKs into CvVariants.
|
||
DropMalformedMySqlTable(conn, "CvVariantVersions", "CreatedAtUtc", "datetime");
|
||
DropMalformedMySqlTable(conn, "CvVariants", "UpdatedAtUtc", "datetime");
|
||
DropMalformedMySqlTable(conn, "AiInteractions", "CreatedAtUtc", "datetime");
|
||
DropMalformedMySqlTable(conn, "ApplicationChecklistItems", "CreatedAtUtc", "datetime");
|
||
DropMalformedMySqlTable(conn, "CoverLetterVersions", "CreatedAtUtc", "datetime");
|
||
DropMalformedMySqlTable(conn, "InterviewPrepItems", "CreatedAtUtc", "datetime");
|
||
|
||
if (!HasMySqlTable(conn, "CvVariants") && HasMySqlTable(conn, "JobApplications"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariants` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`PublicSlug` varchar(64) NOT NULL,
|
||
`Name` varchar(255) NOT NULL,
|
||
`JobApplicationId` int NULL,
|
||
`SettingsJson` longtext NOT NULL,
|
||
`IsPublic` tinyint(1) NOT NULL,
|
||
`Version` int NOT NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CvVariants_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE SET NULL
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "CvVariantVersions") && HasMySqlTable(conn, "CvVariants"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CvVariantVersions` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`CvVariantId` int NOT NULL,
|
||
`Version` int NOT NULL,
|
||
`SettingsJson` longtext NOT NULL,
|
||
`Source` varchar(100) NOT NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CvVariantVersions_CvVariants_CvVariantId` FOREIGN KEY (`CvVariantId`) REFERENCES `CvVariants` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "AiInteractions") && HasMySqlTable(conn, "JobApplications"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `AiInteractions` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`JobApplicationId` int NOT NULL,
|
||
`Module` varchar(64) NOT NULL,
|
||
`Mode` varchar(64) NULL,
|
||
`Title` varchar(255) NOT NULL,
|
||
`Provider` varchar(100) NOT NULL,
|
||
`ResultJson` longtext NOT NULL,
|
||
`InputCharacterCount` int NOT NULL DEFAULT 0,
|
||
`OutputCharacterCount` int NOT NULL DEFAULT 0,
|
||
`EstimatedTokenCount` int NOT NULL DEFAULT 0,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_AiInteractions_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "ApplicationChecklistItems") && HasMySqlTable(conn, "JobApplications"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ApplicationChecklistItems` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`JobApplicationId` int NOT NULL,
|
||
`SystemKey` varchar(64) NULL,
|
||
`AutoSignal` varchar(64) NULL,
|
||
`Title` varchar(255) NOT NULL,
|
||
`Description` longtext NULL,
|
||
`Category` varchar(32) NOT NULL,
|
||
`Status` varchar(32) NOT NULL,
|
||
`Section` varchar(64) NULL,
|
||
`SortOrder` int NOT NULL,
|
||
`IsSystemGenerated` tinyint(1) NOT NULL,
|
||
`IsAutoCompleted` tinyint(1) NOT NULL,
|
||
`CompletedAt` datetime(6) NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_ApplicationChecklistItems_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "CoverLetterVersions") && HasMySqlTable(conn, "JobApplications"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `CoverLetterVersions` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`JobApplicationId` int NOT NULL,
|
||
`Version` int NOT NULL,
|
||
`Text` longtext NOT NULL,
|
||
`Source` varchar(32) NOT NULL,
|
||
`AiAction` varchar(32) NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_CoverLetterVersions_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "InterviewPrepItems") && HasMySqlTable(conn, "JobApplications"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `InterviewPrepItems` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`OwnerUserId` varchar(255) NOT NULL,
|
||
`JobApplicationId` int NOT NULL,
|
||
`Category` varchar(32) NOT NULL,
|
||
`Title` varchar(500) NOT NULL,
|
||
`Content` longtext NULL,
|
||
`Source` varchar(16) NOT NULL,
|
||
`IsPrepared` tinyint(1) NOT NULL,
|
||
`SortOrder` int NOT NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`),
|
||
CONSTRAINT `FK_InterviewPrepItems_JobApplications_JobApplicationId` FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepItems", "Id");
|
||
EnsureMySqlIndex(conn, "InterviewPrepItems", "IX_InterviewPrepItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`");
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CoverLetterVersions", "Id");
|
||
EnsureMySqlIndex(conn, "CoverLetterVersions", "IX_CoverLetterVersions_Owner_Job_Version", "`OwnerUserId`, `JobApplicationId`, `Version`");
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "ApplicationChecklistItems", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariantVersions", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiInteractions", "Id");
|
||
EnsureMySqlColumn(conn, "AiInteractions", "InputCharacterCount", "ALTER TABLE `AiInteractions` ADD COLUMN `InputCharacterCount` int NOT NULL DEFAULT 0;");
|
||
EnsureMySqlColumn(conn, "AiInteractions", "OutputCharacterCount", "ALTER TABLE `AiInteractions` ADD COLUMN `OutputCharacterCount` int NOT NULL DEFAULT 0;");
|
||
EnsureMySqlColumn(conn, "AiInteractions", "EstimatedTokenCount", "ALTER TABLE `AiInteractions` ADD COLUMN `EstimatedTokenCount` int NOT NULL DEFAULT 0;");
|
||
|
||
foreach (var (ixTable, ixName, ixColumns, ixUnique) in new[]
|
||
{
|
||
("CvVariants", "IX_CvVariants_JobApplicationId", "`JobApplicationId`", false),
|
||
("CvVariants", "IX_CvVariants_OwnerUserId_UpdatedAtUtc", "`OwnerUserId`, `UpdatedAtUtc`", false),
|
||
("CvVariants", "IX_CvVariants_PublicSlug", "`PublicSlug`", true),
|
||
("CvVariantVersions", "IX_CvVariantVersions_CvVariantId_Version", "`CvVariantId`, `Version`", false),
|
||
("AiInteractions", "IX_AiInteractions_JobApplicationId", "`JobApplicationId`", false),
|
||
("AiInteractions", "IX_AiInteractions_Owner_Job_Module_Created", "`OwnerUserId`, `JobApplicationId`, `Module`, `CreatedAtUtc`", false),
|
||
("ApplicationChecklistItems", "IX_ApplicationChecklistItems_JobApplicationId_SystemKey", "`JobApplicationId`, `SystemKey`", true),
|
||
("ApplicationChecklistItems", "IX_ApplicationChecklistItems_Owner_Job_Sort", "`OwnerUserId`, `JobApplicationId`, `SortOrder`", false),
|
||
})
|
||
{
|
||
EnsureMySqlIndex(conn, ixTable, ixName, ixColumns, ixUnique);
|
||
}
|
||
|
||
EnsureMySqlIndex(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId", "`OwnerUserId`", unique: true);
|
||
|
||
EnsureMySqlIndex(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version", "`OwnerUserId`, `CareerProfileId`, `Version`");
|
||
|
||
if (!HasMySqlTable(conn, "TwoFactorRecoveryCodes"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`UserId` varchar(255) NOT NULL,
|
||
`CodeHash` varchar(255) NOT NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
`UsedAtUtc` datetime(6) NULL,
|
||
PRIMARY KEY (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TwoFactorRecoveryCodes", "Id");
|
||
|
||
EnsureMySqlIndex(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc", "`UserId`, `UsedAtUtc`");
|
||
|
||
if (!HasMySqlTable(conn, "TrustedDevices"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `TrustedDevices` (
|
||
`Id` int NOT NULL AUTO_INCREMENT,
|
||
`UserId` varchar(255) NOT NULL,
|
||
`TokenHash` varchar(255) NOT NULL,
|
||
`DeviceLabel` varchar(255) NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
`LastSeenAtUtc` datetime(6) NOT NULL,
|
||
`ExpiresAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "TrustedDevices", "Id");
|
||
|
||
EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_UserId", "`UserId`");
|
||
|
||
EnsureMySqlIndex(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash", "`TokenHash`");
|
||
|
||
if (!HasMySqlTable(conn, "UserSessions"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `UserSessions` (
|
||
`Id` varchar(64) NOT NULL,
|
||
`UserId` varchar(255) NOT NULL,
|
||
`DeviceLabel` varchar(255) NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
`LastSeenAtUtc` datetime(6) NOT NULL,
|
||
`ExpiresAtUtc` datetime(6) NOT NULL,
|
||
`RevokedAtUtc` datetime(6) NULL,
|
||
PRIMARY KEY (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
EnsureMySqlIndex(conn, "UserSessions", "IX_UserSessions_UserId", "`UserId`");
|
||
|
||
// Schema reconciliation must never crash app startup: an index that fails
|
||
// (e.g. combined key exceeds MySQL's 3072-byte limit because an older
|
||
// migration made OwnerUserId wider than the varchar(255) this reconciler
|
||
// assumes) is logged and skipped rather than taking prod down. OwnerUserId
|
||
// is prefix-indexed at 191 chars (safe under utf8mb4's 767-byte legacy
|
||
// per-column key limit, and far longer than the GUID-like Identity ids
|
||
// actually stored there) so composite indexes stay well under the cap
|
||
// regardless of the column's declared width.
|
||
// Same guarded creation, but non-fatal: these are optimisation indexes on
|
||
// migration-owned tables, so a failure must degrade performance, never boot.
|
||
void TryCreateIndex(string table, string indexName, string columnsSql, bool unique = false)
|
||
{
|
||
try
|
||
{
|
||
EnsureMySqlIndex(conn, table, indexName, columnsSql, unique);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
app.Logger.LogWarning(ex, "Skipping index {Index} on {Table} during startup reconciliation.", indexName, table);
|
||
}
|
||
}
|
||
|
||
void TryCreateUniqueIndex(string table, string indexName, string columnsSql) =>
|
||
TryCreateIndex(table, indexName, columnsSql, unique: true);
|
||
|
||
TryCreateIndex("Companies", "IX_Companies_OwnerUserId", "`OwnerUserId`(191)");
|
||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId", "`OwnerUserId`(191)");
|
||
|
||
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
|
||
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt).
|
||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted", "`OwnerUserId`(191), `IsDeleted`");
|
||
// FollowUpAt is `text` on MariaDB (the migration was scaffolded against SQLite, which
|
||
// stores DateTimeOffset as TEXT). A text column cannot be indexed without a prefix
|
||
// length, so without one this index ALWAYS failed the 3072-byte key check, was caught
|
||
// and skipped on every boot, and left the follow-up reminder query unindexed — while
|
||
// logging a "Specified key was too long" line the deploy runbook flags as a rollback
|
||
// signal. Prefix it like Status(50) below. ISO-8601 date strings sort lexicographically,
|
||
// so a 20-char prefix ("YYYY-MM-DD HH:MM:SS") keeps the index useful for the reminder scan.
|
||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`(20)");
|
||
// Status is longtext in MySQL (see JobTrackerContext.OnModelCreating), so it
|
||
// needs an explicit prefix length to be indexable under MariaDB's key-length rules.
|
||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_IsDeleted_Status", "`OwnerUserId`(191), `IsDeleted`, `Status`(50)");
|
||
TryCreateIndex("Correspondences", "IX_Correspondences_JobApplicationId", "`JobApplicationId`");
|
||
TryCreateIndex("JobEvents", "IX_JobEvents_JobApplicationId", "`JobApplicationId`");
|
||
TryCreateIndex("CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc", "`OwnerUserId`(191), `UploadedAtUtc`");
|
||
TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc", "`OwnerUserId`(191), `StartedAtUtc`");
|
||
TryCreateIndex("CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId", "`ArtifactId`");
|
||
TryCreateIndex("GmailConnections", "IX_GmailConnections_OwnerUserId", "`OwnerUserId`(191)");
|
||
TryCreateUniqueIndex("GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress", "`OwnerUserId`(191), `GmailAddress`(191)");
|
||
TryCreateIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId", "`OwnerUserId`(191)");
|
||
TryCreateUniqueIndex("MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress", "`OwnerUserId`(191), `MailAddress`(191)");
|
||
TryCreateUniqueIndex("ImapConnections", "IX_ImapConnections_OwnerUserId", "`OwnerUserId`(191)");
|
||
TryCreateUniqueIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId", "`OwnerUserId`(191), `JobApplicationId`");
|
||
TryCreateIndex("TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId", "`JobApplicationId`");
|
||
}
|
||
}
|
||
}
|
||
|
||
// 1. Reconcile what already exists, and create the reconciler-owned tables. This has to run
|
||
// BEFORE Migrate(): legacy schemas need repairing so migrations do not collide with
|
||
// them, and AddCareerProfileRelationalChildren adds children that reference
|
||
// CareerProfiles, a reconciler-owned table. Anything that references a migration-owned
|
||
// table is skipped here (the parent does not exist yet) and picked up in pass 3.
|
||
ReconcileSchema();
|
||
|
||
// 2. Apply one migration at a time, reconciling after each. Some historical migrations
|
||
// rebuild tables using columns owned by the reconciler, so a fresh database needs
|
||
// those columns added after the base table appears and before a later migration reads it.
|
||
try
|
||
{
|
||
using var migrationScope = app.Services.CreateScope();
|
||
var migrationDb = migrationScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||
var migrator = migrationDb.Database.GetService<IMigrator>();
|
||
while (migrationDb.Database.GetPendingMigrations().FirstOrDefault() is { } migration)
|
||
{
|
||
migrator.Migrate(migration);
|
||
ReconcileSchema();
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
app.Logger.LogError(ex, "Database migration failed during startup initialization.");
|
||
throw;
|
||
}
|
||
|
||
// 3. Reconcile again, now that the migration-owned tables exist: creates the
|
||
// reconciler-owned tables that reference them and applies the index / AUTO_INCREMENT
|
||
// repairs that pass 1 had to skip. Idempotent, so on an existing database it is a scan.
|
||
ReconcileSchema();
|
||
|
||
// Optional: seed an initial admin user for local username/password login.
|
||
// Set Auth:AdminEmail and Auth:AdminPassword to enable.
|
||
var adminEmail = (app.Configuration["Auth:AdminEmail"] ?? "").Trim();
|
||
var adminPassword = (app.Configuration["Auth:AdminPassword"] ?? "").Trim();
|
||
if (!string.IsNullOrWhiteSpace(adminEmail) && !string.IsNullOrWhiteSpace(adminPassword))
|
||
{
|
||
using var adminScope = app.Services.CreateScope();
|
||
var adminDb = adminScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||
var adminUsers = adminScope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
||
var adminRoles = adminScope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
|
||
const string adminRole = "Admin";
|
||
|
||
if (!adminRoles.RoleExistsAsync(adminRole).GetAwaiter().GetResult())
|
||
{
|
||
adminRoles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult();
|
||
}
|
||
|
||
var existing = adminUsers.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
|
||
if (existing is null)
|
||
{
|
||
var u = new ApplicationUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true };
|
||
var created = adminUsers.CreateAsync(u, adminPassword).GetAwaiter().GetResult();
|
||
if (created.Succeeded)
|
||
{
|
||
adminUsers.AddToRoleAsync(u, adminRole).GetAwaiter().GetResult();
|
||
app.Logger.LogInformation("Seeded admin user: {Email}", adminEmail);
|
||
}
|
||
else
|
||
{
|
||
app.Logger.LogWarning("Failed to seed admin user: {Errors}", string.Join("; ", created.Errors.Select(e => e.Description)));
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var inRole = adminUsers.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult();
|
||
if (!inRole) adminUsers.AddToRoleAsync(existing, adminRole).GetAwaiter().GetResult();
|
||
}
|
||
|
||
// One-time claim of legacy data for the admin user so enabling auth doesn't "hide" existing records.
|
||
var admin = adminUsers.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
|
||
if (admin is not null)
|
||
{
|
||
try
|
||
{
|
||
using var conn = adminDb.Database.GetDbConnection();
|
||
conn.Open();
|
||
|
||
static bool ColumnExists(DbConnection c, string providerName, string table, string column)
|
||
{
|
||
using var cmd = c.CreateCommand();
|
||
if (providerName is "mysql" or "mariadb")
|
||
{
|
||
var databaseName = c.Database;
|
||
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
|
||
|
||
var schemaParam = cmd.CreateParameter();
|
||
schemaParam.ParameterName = "@schema";
|
||
schemaParam.Value = databaseName;
|
||
cmd.Parameters.Add(schemaParam);
|
||
|
||
var tableParam = cmd.CreateParameter();
|
||
tableParam.ParameterName = "@table";
|
||
tableParam.Value = table;
|
||
cmd.Parameters.Add(tableParam);
|
||
|
||
var columnParam = cmd.CreateParameter();
|
||
columnParam.ParameterName = "@column";
|
||
columnParam.Value = column;
|
||
cmd.Parameters.Add(columnParam);
|
||
}
|
||
else
|
||
{
|
||
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
|
||
}
|
||
|
||
return cmd.ExecuteScalar() is not null;
|
||
}
|
||
|
||
var companyOwnershipExists = ColumnExists(conn, provider, "Companies", "OwnerUserId");
|
||
var jobOwnershipExists = ColumnExists(conn, provider, "JobApplications", "OwnerUserId");
|
||
|
||
if (companyOwnershipExists || jobOwnershipExists)
|
||
{
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
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);
|
||
}
|
||
}
|
||
|
||
var readiness = app.Services.GetRequiredService<IStartupReadiness>();
|
||
readiness.MarkReady();
|
||
|
||
return Task.CompletedTask;
|
||
}
|
||
}
|