Files
jobtrackingapp/JobTrackerApi/Program.cs
T

1127 lines
53 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using JobTrackerApi.Data;
using System.Data.Common;
using MySqlConnector;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.IdentityModel.Tokens;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using System.Diagnostics;
using System.IdentityModel.Tokens.Jwt;
using System.Net;
using System.IO;
using System.Security.Cryptography;
using JobTrackerApi.Services.JobImport;
using JobTrackerApi.Services.JobImport.Plugins;
using JobTrackerApi.Services.JobImport.Translation;
var builder = WebApplication.CreateBuilder(args);
// Avoid Windows EventLog provider issues in local dev environments.
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddSingleton<AppPaths>();
// Add DbContext
builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
{
var cfg = sp.GetRequiredService<IConfiguration>();
var paths = sp.GetRequiredService<AppPaths>();
var provider = (cfg["Database:Provider"] ?? "sqlite").Trim().ToLowerInvariant();
var cs = cfg.GetConnectionString("JobTracker");
if (string.IsNullOrWhiteSpace(cs))
{
cs = $"Data Source={paths.GetDbPath()}";
provider = "sqlite";
}
if (provider is "mysql" or "mariadb")
{
// Avoid ServerVersion.AutoDetect here because it forces an immediate DB connection
// during service registration, which can crash the API if MariaDB is temporarily
// unavailable or on a different network during deploy startup.
options.UseMySql(cs, new MariaDbServerVersion(new Version(11, 0, 0)));
}
else
{
options.UseSqlite(cs);
}
// We create Identity tables on startup in environments where `dotnet ef` isn't available.
// That can cause EF to detect "pending model changes" and throw on Migrate(). Ignore it.
options.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning));
});
// Enable CORS (allowlist by default)
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowReact", policy =>
{
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
if (origins.Length == 0)
{
origins = new[] { "http://localhost:3000" };
}
if (origins.Any(x => x.Trim() == "*"))
{
policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
}
else
{
policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray())
.AllowAnyMethod()
.AllowAnyHeader();
}
});
});
// Add controllers
builder.Services.AddControllers();
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(dataRoot))
{
dataRoot = builder.Environment.ContentRootPath;
}
if (!Path.IsPathRooted(dataRoot))
{
dataRoot = Path.Combine(builder.Environment.ContentRootPath, dataRoot);
}
Directory.CreateDirectory(dataRoot);
var dataProtectionKeysPath = Path.Combine(dataRoot, "keys");
Directory.CreateDirectory(dataProtectionKeysPath);
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath))
.SetApplicationName("JobTracker");
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<FollowUpReminderHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHostedService<SummarizerProbeHostedService>();
builder.Services.AddHttpClient("jobimport")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All
});
// Local AI service (FastAPI). Supports summarization and OCR/text extraction.
builder.Services.AddHttpClient("ai-service", client =>
{
var baseUrl = builder.Configuration["Ai:BaseUrl"]
?? builder.Configuration["Summarizer:BaseUrl"]
?? "http://127.0.0.1:8001";
client.BaseAddress = new Uri(baseUrl);
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
options.User.RequireUniqueEmail = true;
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 8;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<JobTrackerContext>()
.AddSignInManager();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<UniversalJobParser>();
builder.Services.AddSingleton<IJobSitePlugin, FinnPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, NavPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, LinkedInPlugin>();
builder.Services.AddSingleton<IJobSitePlugin, JobbnorgePlugin>();
var translationProvider = (builder.Configuration["Translation:Provider"] ?? "none").Trim().ToLowerInvariant();
builder.Services.AddSingleton<ITranslationService>(sp =>
{
return translationProvider switch
{
"libretranslate" => new LibreTranslateService(sp.GetRequiredService<IHttpClientFactory>(), sp.GetRequiredService<IConfiguration>()),
_ => new NoOpTranslationService()
};
});
builder.Services.AddScoped<JobImportService>();
var requireAuth = builder.Configuration.GetValue("Auth:Require", false);
var googleClientId = (builder.Configuration["Auth:GoogleClientId"] ?? "").Trim();
var jwtKey = (builder.Configuration["Auth:JwtKey"] ?? "").Trim();
var ephemeralJwtKey = false;
if (string.IsNullOrWhiteSpace(jwtKey))
{
if (requireAuth)
throw new InvalidOperationException("Auth is required but Auth:JwtKey is not configured.");
jwtKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(64));
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Auth:JwtKey"] = jwtKey });
ephemeralJwtKey = true;
}
var issuer = (builder.Configuration["Auth:JwtIssuer"] ?? "JobTrackerApi").Trim();
var audience = (builder.Configuration["Auth:JwtAudience"] ?? "job-tracker-ui").Trim();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "smart";
options.DefaultChallengeScheme = "smart";
})
.AddPolicyScheme("smart", "Smart JWT", options =>
{
options.ForwardDefaultSelector = ctx =>
{
if (string.IsNullOrWhiteSpace(googleClientId))
return "local";
var auth = ctx.Request.Headers.Authorization.ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return "local";
var token = auth["Bearer ".Length..].Trim();
var handler = new JwtSecurityTokenHandler();
if (!handler.CanReadToken(token))
return "local";
try
{
var jwt = handler.ReadJwtToken(token);
var iss = jwt.Issuer ?? "";
return iss is "accounts.google.com" or "https://accounts.google.com"
? "google"
: "local";
}
catch
{
return "local";
}
};
})
.AddJwtBearer("local", options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = issuer,
ValidateAudience = true,
ValidAudience = audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(System.Text.Encoding.UTF8.GetBytes(jwtKey)),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(2),
NameClaimType = System.Security.Claims.ClaimTypes.Name,
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
};
});
if (!string.IsNullOrWhiteSpace(googleClientId))
{
builder.Services.AddAuthentication().AddJwtBearer("google", options =>
{
// Validate Google ID tokens (sent from the frontend) as bearer tokens.
options.Authority = "https://accounts.google.com";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuers = new[] { "accounts.google.com", "https://accounts.google.com" },
ValidateAudience = true,
ValidAudience = googleClientId,
ValidateLifetime = true,
};
});
}
builder.Services.AddAuthorization(options =>
{
if (requireAuth)
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
}
});
var app = builder.Build();
if (ephemeralJwtKey)
{
app.Logger.LogWarning("Auth:JwtKey was not configured. Generated an ephemeral key; local login tokens will be invalid after restart.");
}
var enableHttpsRedirect = app.Configuration.GetValue("HttpsRedirection:Enabled", false);
var enableHsts = app.Configuration.GetValue("HttpsRedirection:Hsts", false);
if (enableHsts) app.UseHsts();
if (enableHttpsRedirect) app.UseHttpsRedirection();
// Structured request logging for easy diagnosis.
app.Use(async (ctx, next) =>
{
var sw = Stopwatch.StartNew();
try
{
await next();
sw.Stop();
var sub = ctx.User?.Claims?.FirstOrDefault(c => c.Type is "sub" or "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier")?.Value;
app.Logger.LogInformation(
"HTTP {Method} {Path} {StatusCode} {ElapsedMs}ms trace={TraceId} sub={Sub}",
ctx.Request.Method,
ctx.Request.Path.Value ?? "",
ctx.Response.StatusCode,
sw.ElapsedMilliseconds,
ctx.TraceIdentifier,
sub ?? ""
);
}
catch (Exception ex)
{
sw.Stop();
app.Logger.LogError(
ex,
"HTTP {Method} {Path} 500 {ElapsedMs}ms trace={TraceId}",
ctx.Request.Method,
ctx.Request.Path.Value ?? "",
sw.ElapsedMilliseconds,
ctx.TraceIdentifier
);
throw;
}
});
// 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,
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 bool HasTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$name";
p.Value = table;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
static bool HasColumn(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
return cmd.ExecuteScalar() is not null;
}
static bool HasMigration(DbConnection c, string migrationId)
{
if (!HasTable(c, "__EFMigrationsHistory")) return false;
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$id";
p.Value = migrationId;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
static void Exec(DbConnection c, string sql)
{
using var cmd = c.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
static void EnsureColumn(DbConnection c, string table, string column, string ddl)
{
// Fresh databases won't have the table until EF migrations run.
if (!HasTable(c, table)) return;
if (!HasColumn(c, table, column)) Exec(c, ddl);
}
static void EnsureIdentityTables(DbConnection c)
{
// EF migrations are used for the app schema. In some environments `dotnet ef` isnt available,
// so create the ASP.NET Core Identity tables directly if they dont 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
);
""");
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;");
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
);
""");
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 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");""");
}
EnsureGmailConnectionsTable(conn);
EnsureCvTables(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;");
}
// 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
// the ShortSummary column. Ensure it exists unconditionally if missing.
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
// Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
EnsureColumn(conn, "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;");
// 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 var conn = new MySqlConnection(cs);
conn.Open();
EnsureIdentityTablesMySql(conn);
static bool MySqlColumnExists(MySqlConnection 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;";
cmd.Parameters.AddWithValue("@schema", c.Database);
cmd.Parameters.AddWithValue("@table", table);
cmd.Parameters.AddWithValue("@column", column);
return cmd.ExecuteScalar() is not null;
}
static bool MySqlIndexExists(MySqlConnection 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;";
cmd.Parameters.AddWithValue("@schema", c.Database);
cmd.Parameters.AddWithValue("@table", table);
cmd.Parameters.AddWithValue("@index", indexName);
return cmd.ExecuteScalar() is not null;
}
static bool HasMySqlTable(MySqlConnection 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;";
cmd.Parameters.AddWithValue("@schema", c.Database);
cmd.Parameters.AddWithValue("@table", table);
return cmd.ExecuteScalar() is not null;
}
static void EnsureMySqlColumn(MySqlConnection c, string table, string column, string ddl)
{
using var existsCmd = c.CreateCommand();
existsCmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
existsCmd.Parameters.AddWithValue("@schema", c.Database);
existsCmd.Parameters.AddWithValue("@table", table);
if (existsCmd.ExecuteScalar() is null) return;
if (MySqlColumnExists(c, table, column)) return;
using var ddlCmd = c.CreateCommand();
ddlCmd.CommandText = ddl;
ddlCmd.ExecuteNonQuery();
}
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", "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, "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;");
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;");
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, "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();
}
if (!MySqlIndexExists(conn, "Companies", "IX_Companies_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_Companies_OwnerUserId` ON `Companies` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "JobApplications", "IX_JobApplications_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_JobApplications_OwnerUserId` ON `JobApplications` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CvUploadArtifacts", "IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CvUploadArtifacts_OwnerUserId_UploadedAtUtc` ON `CvUploadArtifacts` (`OwnerUserId`, `UploadedAtUtc`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_OwnerUserId_StartedAtUtc"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_OwnerUserId_StartedAtUtc` ON `CvExtractionRuns` (`OwnerUserId`, `StartedAtUtc`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "CvExtractionRuns", "IX_CvExtractionRuns_ArtifactId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_CvExtractionRuns_ArtifactId` ON `CvExtractionRuns` (`ArtifactId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_TailoredCvDrafts_OwnerUserId_JobApplicationId` ON `TailoredCvDrafts` (`OwnerUserId`, `JobApplicationId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_JobApplicationId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_TailoredCvDrafts_JobApplicationId` ON `TailoredCvDrafts` (`JobApplicationId`);";
cmd.ExecuteNonQuery();
}
}
}
db.Database.Migrate();
// 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))
{
const string adminRole = "Admin";
if (!roles.RoleExistsAsync(adminRole).GetAwaiter().GetResult())
{
roles.CreateAsync(new IdentityRole(adminRole)).GetAwaiter().GetResult();
}
var existing = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
if (existing is null)
{
var u = new ApplicationUser { UserName = adminEmail, Email = adminEmail, EmailConfirmed = true };
var created = users.CreateAsync(u, adminPassword).GetAwaiter().GetResult();
if (created.Succeeded)
{
users.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 = users.IsInRoleAsync(existing, adminRole).GetAwaiter().GetResult();
if (!inRole) users.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 = users.FindByEmailAsync(adminEmail).GetAwaiter().GetResult();
if (admin is not null)
{
try
{
using var conn = db.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)
{
db.Database.ExecuteSqlRaw("UPDATE Companies SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
}
if (jobOwnershipExists)
{
db.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.");
}
}
}
}
app.UseCors("AllowReact");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
app.Run();