1127 lines
53 KiB
C#
1127 lines
53 KiB
C#
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` 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
|
||
);
|
||
""");
|
||
|
||
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();
|