refactor(db): migrate email connections

Move Gmail, Microsoft Graph, and IMAP connection creation into an additive provider-aware migration. Preserve encrypted credentials and retain scoped historical repairs.
This commit is contained in:
cesnimda
2026-08-30 16:56:14 +02:00
parent 2e2d649f6f
commit 6c50e406b5
9 changed files with 274 additions and 163 deletions
@@ -105,6 +105,9 @@ public sealed class MigrationChainTests
Assert.Contains("CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `TwoFactorRecoveryCodes`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `TrustedDevices`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `TrustedDevices`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `UserSessions`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `UserSessions`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `GmailConnections`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `MicrosoftGraphConnections`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `ImapConnections`", script, StringComparison.Ordinal);
Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal); Assert.Contains("`UiLanguage` varchar(16)", script, StringComparison.Ordinal);
Assert.All( Assert.All(
Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value), Regex.Matches(script, "CONSTRAINT `([^`]+)`").Select(match => match.Groups[1].Value),
@@ -303,6 +306,87 @@ public sealed class MigrationChainTests
Assert.Empty(await db.Database.GetPendingMigrationsAsync()); Assert.Empty(await db.Database.GetPendingMigrationsAsync());
} }
[Fact]
public async Task Email_connection_adoption_preserves_encrypted_credentials()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using var db = Context(connection);
var migrator = db.GetService<IMigrator>();
await migrator.MigrateAsync("20260830123000_AdoptAuthenticationSupportSchema");
await ExecuteAsync(connection, """
CREATE TABLE "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
);
INSERT INTO "GmailConnections"
("OwnerUserId", "GmailAddress", "EncryptedRefreshToken", "Scope", "ConnectedAt")
VALUES ('owner-fixture', 'gmail@example.invalid', 'gmail-secret', 'mail.read',
'2026-08-30T09:00:00+00:00');
CREATE TABLE "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
);
INSERT INTO "MicrosoftGraphConnections"
("OwnerUserId", "MailAddress", "EncryptedRefreshToken", "Scope", "ConnectedAt")
VALUES ('owner-fixture', 'graph@example.invalid', 'graph-secret', 'Mail.Read',
'2026-08-30T09:00:00+00:00');
CREATE TABLE "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
);
INSERT INTO "ImapConnections"
("OwnerUserId", "Host", "Port", "UseSsl", "Username", "EncryptedPassword", "ConnectedAt")
VALUES ('owner-fixture', 'imap.example.invalid', 993, 1, 'imap-user', 'imap-secret',
'2026-08-30T09:00:00+00:00');
""");
await migrator.MigrateAsync();
Assert.Equal("gmail-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM GmailConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("graph-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM MicrosoftGraphConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("imap-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedPassword FROM ImapConnections WHERE OwnerUserId = 'owner-fixture';"));
await migrator.MigrateAsync("20260830123000_AdoptAuthenticationSupportSchema");
Assert.Equal("gmail-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM GmailConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("graph-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedRefreshToken FROM MicrosoftGraphConnections WHERE OwnerUserId = 'owner-fixture';"));
Assert.Equal("imap-secret", await ScalarAsync<string>(connection,
"SELECT EncryptedPassword FROM ImapConnections WHERE OwnerUserId = 'owner-fixture';"));
await migrator.MigrateAsync();
Assert.Equal(5L, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master
WHERE type = 'index' AND name IN (
'IX_GmailConnections_OwnerUserId',
'IX_GmailConnections_OwnerUserId_GmailAddress',
'IX_MicrosoftGraphConnections_OwnerUserId',
'IX_MicrosoftGraphConnections_OwnerUserId_MailAddress',
'IX_ImapConnections_OwnerUserId');
"""));
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
}
private static JobTrackerContext Context(SqliteConnection connection) private static JobTrackerContext Context(SqliteConnection connection)
{ {
var currentUser = new Mock<ICurrentUserService>(); var currentUser = new Mock<ICurrentUserService>();
@@ -0,0 +1,164 @@
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations;
/// <summary>
/// Moves provider connection-table creation into the migration chain while preserving encrypted
/// credentials and sync state stored by earlier startup-reconciled installations.
/// </summary>
[DbContext(typeof(JobTrackerContext))]
[Migration("20260830124000_AdoptEmailConnectionSchema")]
public sealed class AdoptEmailConnectionSchema : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase))
{
migrationBuilder.Sql("""
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`)
) CHARACTER SET=utf8mb4;
CREATE INDEX IF NOT EXISTS `IX_GmailConnections_OwnerUserId`
ON `GmailConnections` (`OwnerUserId`(191));
CREATE UNIQUE INDEX IF NOT EXISTS `IX_GmailConnections_OwnerUserId_GmailAddress`
ON `GmailConnections` (`OwnerUserId`(191), `GmailAddress`(191));
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`)
) CHARACTER SET=utf8mb4;
CREATE INDEX IF NOT EXISTS `IX_MicrosoftGraphConnections_OwnerUserId`
ON `MicrosoftGraphConnections` (`OwnerUserId`(191));
CREATE UNIQUE INDEX IF NOT EXISTS `IX_MicrosoftGraphConnections_OwnerUserId_MailAddress`
ON `MicrosoftGraphConnections` (`OwnerUserId`(191), `MailAddress`(191));
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`)
) CHARACTER SET=utf8mb4;
CREATE UNIQUE INDEX IF NOT EXISTS `IX_ImapConnections_OwnerUserId`
ON `ImapConnections` (`OwnerUserId`(191));
""");
return;
}
migrationBuilder.Sql("""
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
);
CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId"
ON "GmailConnections" ("OwnerUserId");
CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress"
ON "GmailConnections" ("OwnerUserId", "GmailAddress");
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
);
CREATE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId"
ON "MicrosoftGraphConnections" ("OwnerUserId");
CREATE UNIQUE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress"
ON "MicrosoftGraphConnections" ("OwnerUserId", "MailAddress");
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
);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_ImapConnections_OwnerUserId"
ON "ImapConnections" ("OwnerUserId");
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Preserve encrypted credentials and sync state from pre-migration installations.
}
}
@@ -536,88 +536,14 @@ public static class StartupInitializationExtensions
// UiLanguage is migration-owned (AddUiLanguagePreference). Adding it here before // UiLanguage is migration-owned (AddUiLanguagePreference). Adding it here before
// the per-migration loop makes a fresh database fail when that migration runs. // the per-migration loop makes a fresh database fail when that migration runs.
static void EnsureGmailConnectionsTable(DbConnection c) static void ReconcileGmailConnectionColumns(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
);
""");
EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT 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", "LastSyncSucceededAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSucceededAt TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncMode", "ALTER TABLE GmailConnections ADD COLUMN LastSyncMode 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", "LastSyncSource", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSource TEXT NULL;");
EnsureColumn(c, "GmailConnections", "LastSyncStatus", "ALTER TABLE GmailConnections ADD COLUMN LastSyncStatus 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;"); 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) static void EnsureCvTables(DbConnection c)
@@ -1024,9 +950,7 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_InterviewPrepItems_Owner_Job_Sort" ON "InterviewPrepItems" ("OwnerUserId", "JobApplicationId", "SortOrder");"""); Exec(c, """CREATE INDEX IF NOT EXISTS "IX_InterviewPrepItems_Owner_Job_Sort" ON "InterviewPrepItems" ("OwnerUserId", "JobApplicationId", "SortOrder");""");
} }
EnsureGmailConnectionsTable(conn); ReconcileGmailConnectionColumns(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn); EnsureCvTables(conn);
EnsureCareerProfileTables(conn); EnsureCareerProfileTables(conn);
EnsureInterviewPrepNotesTable(conn); EnsureInterviewPrepNotesTable(conn);
@@ -1412,30 +1336,6 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery(); 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", "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", "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", "LastSyncMode", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncMode` varchar(255) NULL;");
@@ -1443,54 +1343,6 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` 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;"); EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;");
if (!HasMySqlTable(conn, "MicrosoftGraphConnections"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `MicrosoftGraphConnections` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`MailAddress` varchar(512) NOT NULL,
`EncryptedRefreshToken` longtext NOT NULL,
`EncryptedAccessToken` longtext NULL,
`AccessTokenExpiresAt` datetime(6) NULL,
`Scope` longtext NOT NULL,
`ConnectedAt` datetime(6) NOT NULL,
`LastSyncedAt` datetime(6) NULL,
`LastSyncAttemptedAt` datetime(6) NULL,
`LastSyncSucceededAt` datetime(6) NULL,
`LastSyncMode` varchar(255) NULL,
`LastSyncSource` varchar(255) NULL,
`LastSyncStatus` varchar(255) NULL,
`LastSyncError` longtext NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "ImapConnections"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ImapConnections` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`Host` varchar(255) NOT NULL,
`Port` int NOT NULL,
`UseSsl` tinyint(1) NOT NULL,
`Username` varchar(255) NOT NULL,
`EncryptedPassword` longtext NOT NULL,
`ConnectedAt` datetime(6) NOT NULL,
`LastSyncedAt` datetime(6) NULL,
`LastSyncAttemptedAt` datetime(6) NULL,
`LastSyncSucceededAt` datetime(6) NULL,
`LastSyncMode` varchar(255) NULL,
`LastSyncSource` varchar(255) NULL,
`LastSyncStatus` varchar(255) NULL,
`LastSyncError` longtext NULL,
PRIMARY KEY (`Id`)
);";
cmd.ExecuteNonQuery();
}
if (!HasMySqlTable(conn, "TailoredCvDrafts") && HasMySqlTable(conn, "JobApplications")) if (!HasMySqlTable(conn, "TailoredCvDrafts") && HasMySqlTable(conn, "JobApplications"))
{ {
using var cmd = conn.CreateCommand(); using var cmd = conn.CreateCommand();
@@ -17,10 +17,13 @@ internal static class StartupSchemaOwnership
"Correspondences", "Correspondences",
"EmailDrafts", "EmailDrafts",
"EmailSendAttempts", "EmailSendAttempts",
"GmailConnections",
"GmailReviewDecisions", "GmailReviewDecisions",
"ImapConnections",
"JobApplications", "JobApplications",
"JobEvents", "JobEvents",
"Jobs", "Jobs",
"MicrosoftGraphConnections",
"RuleSettings", "RuleSettings",
"SystemEmailSettings", "SystemEmailSettings",
"TrustedDevices", "TrustedDevices",
@@ -56,11 +59,8 @@ internal static class StartupSchemaOwnership
"CvUploadArtifacts", "CvUploadArtifacts",
"CvVariants", "CvVariants",
"CvVariantVersions", "CvVariantVersions",
"GmailConnections",
"ImapConnections",
"InterviewPrepItems", "InterviewPrepItems",
"InterviewPrepNotes", "InterviewPrepNotes",
"MicrosoftGraphConnections",
"TailoredCvDrafts", "TailoredCvDrafts",
}; };
+1 -1
View File
@@ -558,7 +558,7 @@ SEC-008 implements the same durable state machine with `<final>.uploading` and `
### P3-1 — Reduce dual schema ownership incrementally ### P3-1 — Reduce dual schema ownership incrementally
**Status (2026-08-30): in progress.** All 49 model tables now have an executable, disjoint creation-owner classification. `SystemEmailSettings`, `UserRuleSettings`, `GmailReviewDecisions`, `TwoFactorRecoveryCodes`, `TrustedDevices`, and `UserSessions` have moved through additive provider-aware migrations with legacy-row preservation, downgrade/retry safety, startup-DDL removal, fresh SQLite runtime proof and generated MariaDB SQL. The Gmail decision migration also closes its missing MariaDB creation path. Twenty-nine startup-created tables remain; see `docs/verification/jt-019-schema-ownership.md` and V-194V-197. **Status (2026-08-30): in progress.** All 49 model tables now have an executable, disjoint creation-owner classification. Nine formerly reconciler-created tables—including authentication support and all three email-provider connectionshave moved through additive provider-aware migrations with legacy-row preservation, downgrade/retry safety, startup-DDL removal, fresh SQLite runtime proof and generated MariaDB SQL. The Gmail decision migration also closes its missing MariaDB creation path. Twenty-six startup-created tables remain; see `docs/verification/jt-019-schema-ownership.md` and V-194V-198.
- **Findings/scope:** JT-019; inventory reconciler operations and move stable schema ownership to EF migrations in small steps. - **Findings/scope:** JT-019; inventory reconciler operations and move stable schema ownership to EF migrations in small steps.
- **Dependencies:** provider upgrade fixtures and P2-2 restore safety. - **Dependencies:** provider upgrade fixtures and P2-2 restore safety.
+1
View File
@@ -229,3 +229,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
| V-195 | Ownership/migration chain; populated adoption/downgrade/retry; MariaDB script; full backend | Repository root / disposable local SQLite | Transfer the independent UserRuleSettings table from both startup-DDL paths to migration ownership without losing per-user settings | PASS — focused ownership/migration 7/7; representative owner row survives adoption, downgrade and re-upgrade; generated MariaDB SQL contains provider-safe DDL; startup no longer contains the table create; full backend 723/723 | MariaDB SQL generated only; no production migration. Down preserves ambiguous legacy data by design. Thirty-three reconciler-owned tables remain | JT-019 second leaf transfer complete; dependency groups continue incrementally | | V-195 | Ownership/migration chain; populated adoption/downgrade/retry; MariaDB script; full backend | Repository root / disposable local SQLite | Transfer the independent UserRuleSettings table from both startup-DDL paths to migration ownership without losing per-user settings | PASS — focused ownership/migration 7/7; representative owner row survives adoption, downgrade and re-upgrade; generated MariaDB SQL contains provider-safe DDL; startup no longer contains the table create; full backend 723/723 | MariaDB SQL generated only; no production migration. Down preserves ambiguous legacy data by design. Thirty-three reconciler-owned tables remain | JT-019 second leaf transfer complete; dependency groups continue incrementally |
| V-196 | Ownership/migration chain; populated adoption/downgrade/retry; MariaDB script; full backend | Repository root / disposable local SQLite | Move GmailReviewDecisions to migration ownership and close the missing MariaDB table path | PASS — focused ownership/migration 8/8; representative decision survives adoption, downgrade and re-upgrade; generated MariaDB SQL contains provider-safe DDL; SQLite startup create is removed; full backend 724/724 | MariaDB SQL generated only; no provider account or production migration. Down preserves ambiguous legacy data by design. Thirty-two reconciler-owned tables remain | JT-019 third leaf transfer complete; provider runtime and dependency groups continue incrementally | | V-196 | Ownership/migration chain; populated adoption/downgrade/retry; MariaDB script; full backend | Repository root / disposable local SQLite | Move GmailReviewDecisions to migration ownership and close the missing MariaDB table path | PASS — focused ownership/migration 8/8; representative decision survives adoption, downgrade and re-upgrade; generated MariaDB SQL contains provider-safe DDL; SQLite startup create is removed; full backend 724/724 | MariaDB SQL generated only; no provider account or production migration. Down preserves ambiguous legacy data by design. Thirty-two reconciler-owned tables remain | JT-019 third leaf transfer complete; provider runtime and dependency groups continue incrementally |
| V-197 | Ownership/migration chain; populated adoption/downgrade/retry; index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move the authentication-support group to migration ownership without invalidating recovery codes, trusted devices, or active sessions | PASS — focused ownership/migration 9/9; representative rows survive adoption, downgrade and re-upgrade; expected indexes exist; generated MariaDB SQL contains all three provider-safe definitions; both startup-create paths are removed; full backend 725/725 | MariaDB SQL generated only; no provider account or production migration. Guarded MariaDB index/auto-increment repairs remain temporarily. Twenty-nine reconciler-owned tables remain | JT-019 authentication-support transfer complete; dependent feature groups continue incrementally | | V-197 | Ownership/migration chain; populated adoption/downgrade/retry; index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move the authentication-support group to migration ownership without invalidating recovery codes, trusted devices, or active sessions | PASS — focused ownership/migration 9/9; representative rows survive adoption, downgrade and re-upgrade; expected indexes exist; generated MariaDB SQL contains all three provider-safe definitions; both startup-create paths are removed; full backend 725/725 | MariaDB SQL generated only; no provider account or production migration. Guarded MariaDB index/auto-increment repairs remain temporarily. Twenty-nine reconciler-owned tables remain | JT-019 authentication-support transfer complete; dependent feature groups continue incrementally |
| V-198 | Ownership/migration chain; populated adoption/downgrade/retry; encrypted-value and index assertions; MariaDB script; full backend | Repository root / disposable local SQLite | Move Gmail, Microsoft Graph, and IMAP connection records to migration ownership without losing credentials or sync state | PASS — focused ownership/migration 10/10; representative encrypted values survive adoption, downgrade and re-upgrade; all five expected indexes exist; generated MariaDB SQL contains all three provider-safe definitions; both startup-create paths are removed; full backend 726/726 | MariaDB SQL generated only; no provider account or production migration. Gmail column repair and guarded MariaDB index/auto-increment repairs remain temporarily. Twenty-six reconciler-owned tables remain | JT-019 email-provider transfer complete; dependent CV/career groups continue incrementally |
+10 -6
View File
@@ -67,10 +67,10 @@ already-correct database. Two consequences worth knowing:
Created by EF migrations, never by the reconciler: Created by EF migrations, never by the reconciler:
`AccountDeletionFiles`, `AccountDeletionRequests`, `AiUsageRecords`, `Attachments`, `Companies`, `AccountDeletionFiles`, `AccountDeletionRequests`, `AiUsageRecords`, `Attachments`, `Companies`,
`Correspondences`, `EmailDrafts`, `EmailSendAttempts`, `GmailReviewDecisions`, `JobApplications`, `Correspondences`, `EmailDrafts`, `EmailSendAttempts`, `GmailConnections`,
`JobEvents`, `Jobs`, `RuleSettings`, `SystemEmailSettings`, `TrustedDevices`, `GmailReviewDecisions`, `ImapConnections`, `JobApplications`, `JobEvents`, `Jobs`,
`TwoFactorRecoveryCodes`, `UserNotifications`, `UserOperations`, `UserRuleSettings`, and `MicrosoftGraphConnections`, `RuleSettings`, `SystemEmailSettings`, `TrustedDevices`,
`UserSessions`. `TwoFactorRecoveryCodes`, `UserNotifications`, `UserOperations`, `UserRuleSettings`, and `UserSessions`.
`SystemEmailSettings` is the first completed ownership transfer: migration `SystemEmailSettings` is the first completed ownership transfer: migration
`20260830120000_AdoptSystemEmailSettingsSchema` creates it for both supported providers and preserves `20260830120000_AdoptSystemEmailSettingsSchema` creates it for both supported providers and preserves
@@ -87,6 +87,11 @@ moved in `20260830123000_AdoptAuthenticationSupportSchema`. These tables deliber
database foreign key to `AspNetUsers` because they are queried during authentication before a database foreign key to `AspNetUsers` because they are queried during authentication before a
current-user scope exists. current-user scope exists.
The email-provider connection group (`GmailConnections`, `MicrosoftGraphConnections`, and
`ImapConnections`) moved in `20260830124000_AdoptEmailConnectionSchema`. Startup retains only
additive Gmail sync-column repair plus guarded MariaDB identity/index repair; it does not create
these tables.
The reconciler may **repair** these (add a missing column, add an index, fix a non-`AUTO_INCREMENT` The reconciler may **repair** these (add a missing column, add an index, fix a non-`AUTO_INCREMENT`
primary key) and may seed the default `RuleSettings` row — but it must never `CREATE TABLE` them. primary key) and may seed the default `RuleSettings` row — but it must never `CREATE TABLE` them.
It used to create `RuleSettings`, which is precisely why a clean install failed with It used to create `RuleSettings`, which is precisely why a clean install failed with
@@ -96,8 +101,7 @@ It used to create `RuleSettings`, which is precisely why a clean install failed
Created by `StartupInitializationExtensions`, with a **no-op migration** holding the model snapshot: Created by `StartupInitializationExtensions`, with a **no-op migration** holding the model snapshot:
`CvUploadArtifacts`, `CvExtractionRuns`, `CvUploadArtifacts`, `CvExtractionRuns`, `TailoredCvDrafts`,
`GmailConnections`, `MicrosoftGraphConnections`, `ImapConnections`, `TailoredCvDrafts`,
`CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`, `CareerProfiles`, `CareerProfileVersions`, the six CareerProfile children (`CareerExperiences`,
`CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`), `CareerEducations`, `CareerSkills`, `CareerProjects`, `CareerCertifications`, `CareerLanguages`),
`InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `CvVariantVersions`, `AiInteractions`, `InterviewPrepNotes`, `AiWorkspaceNotes`, `CvVariants`, `CvVariantVersions`, `AiInteractions`,
+7 -2
View File
@@ -22,6 +22,9 @@ cohesive table groups. It does not attempt to delete the legacy reconciler whole
- Added `20260830123000_AdoptAuthenticationSupportSchema` for recovery codes, trusted devices, and - Added `20260830123000_AdoptAuthenticationSupportSchema` for recovery codes, trusted devices, and
revocable user sessions; both provider startup-create paths are removed while guarded MariaDB revocable user sessions; both provider startup-create paths are removed while guarded MariaDB
repair checks remain for historical schemas. repair checks remain for historical schemas.
- Added `20260830124000_AdoptEmailConnectionSchema` for Gmail, Microsoft Graph, and IMAP connection
records. Encrypted credentials and sync state are retained; scoped Gmail column repair and guarded
MariaDB index/identity repair remain for historical installations.
- Corrected the ownership runbook: new tables now default to migrations; the reconciler is legacy - Corrected the ownership runbook: new tables now default to migrations; the reconciler is legacy
compatibility code to retire one dependency group at a time. compatibility code to retire one dependency group at a time.
@@ -43,14 +46,16 @@ migration.
- A representative Gmail review decision survives adoption, downgrade, and re-upgrade. - A representative Gmail review decision survives adoption, downgrade, and re-upgrade.
- Representative recovery-code, trusted-device, and user-session rows survive adoption, downgrade, - Representative recovery-code, trusted-device, and user-session rows survive adoption, downgrade,
and re-upgrade, and their indexes are present afterwards. and re-upgrade, and their indexes are present afterwards.
- Representative encrypted credentials for all three email providers survive adoption, downgrade,
and re-upgrade, and all five expected provider indexes are present afterwards.
- Generated MariaDB SQL contains the provider-correct `SystemEmailSettings` DDL. - Generated MariaDB SQL contains the provider-correct `SystemEmailSettings` DDL.
- Full backend: 725/725 passed after the authentication-support transfer. - Full backend: 726/726 passed after the email-provider connection transfer.
- Fresh application startup over a new disposable SQLite database applied - Fresh application startup over a new disposable SQLite database applied
`20260830120000_AdoptSystemEmailSettingsSchema` and reached the healthy listening state. `20260830120000_AdoptSystemEmailSettingsSchema` and reached the healthy listening state.
## Remaining JT-019 work ## Remaining JT-019 work
Twenty-nine model tables remain startup-created, including the Identity group and several tables Twenty-six model tables remain startup-created, including the Identity group and several tables
with parent dependencies. Transfer them in small dependency-aware migrations with blank, populated, with parent dependencies. Transfer them in small dependency-aware migrations with blank, populated,
retry and MariaDB runtime proof. Column/index repairs must remain until historical upgrade fixtures retry and MariaDB runtime proof. Column/index repairs must remain until historical upgrade fixtures
prove each one redundant. prove each one redundant.
+2 -1
View File
@@ -47,6 +47,7 @@ Updated: 2026-08-30
- Transferred the independent `UserRuleSettings` table from both provider startup paths to its own provider-aware migration; owner-keyed settings survive adoption, downgrade and retry. - Transferred the independent `UserRuleSettings` table from both provider startup paths to its own provider-aware migration; owner-keyed settings survive adoption, downgrade and retry.
- Moved `GmailReviewDecisions` into a provider-aware migration, preserving existing SQLite decisions and closing the previously missing MariaDB table path. - Moved `GmailReviewDecisions` into a provider-aware migration, preserving existing SQLite decisions and closing the previously missing MariaDB table path.
- Moved recovery codes, trusted devices, and revocable user sessions into one provider-aware authentication-support migration; populated legacy rows and indexes survive adoption, downgrade, and retry. - Moved recovery codes, trusted devices, and revocable user sessions into one provider-aware authentication-support migration; populated legacy rows and indexes survive adoption, downgrade, and retry.
- Moved Gmail, Microsoft Graph, and IMAP connection records into one provider-aware migration; encrypted credentials, sync state, uniqueness constraints, and historical repair paths are preserved.
### In progress ### In progress
@@ -77,7 +78,7 @@ Updated: 2026-08-30
- Focused frontend: 2 suites, 6 tests passed. - Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed. - Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed. - Next production build and TypeScript: passed.
- Full backend: 725/725 tests passed after the authentication-support JT-019 transfer. - Full backend: 726/726 tests passed after the email-provider JT-019 transfer.
- Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors. - Portable Playwright launcher: resolved the user-local .NET 9 SDK; backend Release build passed with 0 warnings/errors.
- Playwright: initial full run 9/10 exposed the intentional mobile Settings control change; updated focused rerun passed 1/1. A final complete browser rerun remains in the end-of-batch gate. - Playwright: initial full run 9/10 exposed the intentional mobile Settings control change; updated focused rerun passed 1/1. A final complete browser rerun remains in the end-of-batch gate.
- Focused backend match/intelligence verification: 34/34 passed. - Focused backend match/intelligence verification: 34/34 passed.