3a906b881e
Evolve the existing readiness workflow into one persisted, user-controlled checklist rather than adding a second tracker. ApplicationChecklistItem records only completion state and user intent. Each default system item carries a stable SystemKey and an AutoSignal — the same signal /readiness already computed — and re-syncs on every read: a satisfied signal auto-completes the item, a reverted signal reopens it, and a manual tick always wins. Users can add, reorder, dismiss and delete. Readiness is refactored into a projection of the checklist (score = completion percentage, completed/missing = live items by status). Its DTO shape and the workflowSignal/reminders health view are unchanged, so no API contract breaks. The workspace's next recommended action now comes from the first pending checklist item in category priority order (preparation, submission, follow-up, interview, custom), replacing the parallel ruleset — so the overview can never recommend something already ticked off, and a user's own task can be next. The table follows the established MariaDB-safe path: the scaffolded migration is a no-op and the idempotent reconciler owns the DDL for both providers. Verified on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both indexes inside the key limit, cascade delete, unique system key per application, and NULL system keys not colliding for custom items. 329 backend tests, 94 frontend tests, type check, production build and both Docker builds pass locally. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1805 lines
103 KiB
C#
1805 lines
103 KiB
C#
using System.Data.Common;
|
||
using JobTrackerApi.Data;
|
||
using JobTrackerApi.Models;
|
||
using Microsoft.AspNetCore.Builder;
|
||
using Microsoft.AspNetCore.Identity;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
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;
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
|
||
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";
|
||
|
||
using DbConnection conn = db.Database.GetDbConnection();
|
||
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;");
|
||
|
||
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,
|
||
"Version" INTEGER NOT NULL,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
"UpdatedAtUtc" TEXT NOT NULL
|
||
);
|
||
""");
|
||
|
||
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");""");
|
||
}
|
||
|
||
// 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,
|
||
"CreatedAtUtc" TEXT NOT NULL,
|
||
CONSTRAINT "FK_AiInteractions_JobApplications_JobApplicationId" FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE
|
||
);
|
||
""");
|
||
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");""");
|
||
}
|
||
|
||
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);
|
||
|
||
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
|
||
// and at least one of the new columns already exists.
|
||
var isLegacy =
|
||
HasMigration(conn, "20260310174114_AddCorrespondence") &&
|
||
!HasMigration(conn, legacyMigrationId) &&
|
||
(HasColumn(conn, "Companies", "Source") || HasColumn(conn, "JobApplications", "IsDeleted"));
|
||
|
||
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))
|
||
{
|
||
using DbConnection conn = db.Database.GetDbConnection();
|
||
conn.Open();
|
||
EnsureIdentityTablesMySql(conn);
|
||
|
||
static bool MySqlIndexExists(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 p4 = cmd.CreateParameter(); p4.ParameterName = "@index"; p4.Value = indexName; cmd.Parameters.Add(p4);
|
||
|
||
return cmd.ExecuteScalar() is not null;
|
||
}
|
||
|
||
|
||
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");
|
||
|
||
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "InterviewPrepNotes", "Id");
|
||
|
||
if (!MySqlIndexExists(conn, "InterviewPrepNotes", "IX_InterviewPrepNotes_OwnerUserId_JobApplicationId"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_InterviewPrepNotes_OwnerUserId_JobApplicationId` ON `InterviewPrepNotes` (`OwnerUserId`, `JobApplicationId`);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiWorkspaceNotes", "Id");
|
||
|
||
if (!MySqlIndexExists(conn, "AiWorkspaceNotes", "IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_AiWorkspaceNotes_OwnerUserId_JobApplicationId_NoteType` ON `AiWorkspaceNotes` (`OwnerUserId`, `JobApplicationId`, `NoteType`);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
// 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;");
|
||
|
||
if (!HasMySqlTable(conn, "RuleSettings"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `RuleSettings` (
|
||
`Id` int 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 (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
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"))
|
||
{
|
||
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"))
|
||
{
|
||
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,
|
||
`Version` int NOT NULL,
|
||
`CreatedAtUtc` datetime(6) NOT NULL,
|
||
`UpdatedAtUtc` datetime(6) NOT NULL,
|
||
PRIMARY KEY (`Id`)
|
||
);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!HasMySqlTable(conn, "CareerProfileVersions"))
|
||
{
|
||
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"))
|
||
{
|
||
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"))
|
||
{
|
||
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");
|
||
|
||
if (!HasMySqlTable(conn, "CvVariants"))
|
||
{
|
||
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"))
|
||
{
|
||
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"))
|
||
{
|
||
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,
|
||
`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"))
|
||
{
|
||
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();
|
||
}
|
||
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "ApplicationChecklistItems", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariants", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvVariantVersions", "Id");
|
||
EnsureMySqlAutoIncrementPrimaryKey(conn, "AiInteractions", "Id");
|
||
|
||
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),
|
||
})
|
||
{
|
||
if (MySqlIndexExists(conn, ixTable, ixName)) continue;
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = $"CREATE {(ixUnique ? "UNIQUE " : string.Empty)}INDEX `{ixName}` ON `{ixTable}` ({ixColumns});";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!MySqlIndexExists(conn, "CareerProfiles", "IX_CareerProfiles_OwnerUserId"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_CareerProfiles_OwnerUserId` ON `CareerProfiles` (`OwnerUserId`);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!MySqlIndexExists(conn, "CareerProfileVersions", "IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "CREATE INDEX `IX_CareerProfileVersions_OwnerUserId_CareerProfileId_Version` ON `CareerProfileVersions` (`OwnerUserId`, `CareerProfileId`, `Version`);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
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");
|
||
|
||
if (!MySqlIndexExists(conn, "TwoFactorRecoveryCodes", "IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "CREATE INDEX `IX_TwoFactorRecoveryCodes_UserId_UsedAtUtc` ON `TwoFactorRecoveryCodes` (`UserId`, `UsedAtUtc`);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
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");
|
||
|
||
if (!MySqlIndexExists(conn, "TrustedDevices", "IX_TrustedDevices_UserId"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "CREATE INDEX `IX_TrustedDevices_UserId` ON `TrustedDevices` (`UserId`);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
if (!MySqlIndexExists(conn, "TrustedDevices", "IX_TrustedDevices_TokenHash"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "CREATE INDEX `IX_TrustedDevices_TokenHash` ON `TrustedDevices` (`TokenHash`);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
if (!MySqlIndexExists(conn, "UserSessions", "IX_UserSessions_UserId"))
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = "CREATE INDEX `IX_UserSessions_UserId` ON `UserSessions` (`UserId`);";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
|
||
// 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.
|
||
void TryCreateIndex(string table, string indexName, string columnsSql)
|
||
{
|
||
if (MySqlIndexExists(conn, table, indexName)) return;
|
||
try
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = $"CREATE INDEX `{indexName}` ON `{table}` ({columnsSql});";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
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)
|
||
{
|
||
if (MySqlIndexExists(conn, table, indexName)) return;
|
||
try
|
||
{
|
||
using var cmd = conn.CreateCommand();
|
||
cmd.CommandText = $"CREATE UNIQUE INDEX `{indexName}` ON `{table}` ({columnsSql});";
|
||
cmd.ExecuteNonQuery();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
app.Logger.LogWarning(ex, "Skipping unique index {Index} on {Table} during startup reconciliation.", indexName, table);
|
||
}
|
||
}
|
||
|
||
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`");
|
||
TryCreateIndex("JobApplications", "IX_JobApplications_OwnerUserId_FollowUpAt", "`OwnerUserId`(191), `FollowUpAt`");
|
||
// 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`");
|
||
}
|
||
}
|
||
|
||
try
|
||
{
|
||
using var migrationScope = app.Services.CreateScope();
|
||
var migrationDb = migrationScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||
migrationDb.Database.Migrate();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
app.Logger.LogError(ex, "Database migration failed during startup initialization.");
|
||
throw;
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
}
|