First Commit
This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using JobTrackerApi.Data;
|
||||
using System.Data.Common;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using System.Diagnostics;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
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.AddSingleton<IAppEmailSender, SmtpEmailSender>();
|
||||
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
|
||||
// Add DbContext
|
||||
builder.Services.AddDbContext<JobTrackerContext>((sp, options) =>
|
||||
{
|
||||
var cfg = sp.GetRequiredService<IConfiguration>();
|
||||
var paths = sp.GetRequiredService<AppPaths>();
|
||||
|
||||
var cs = cfg.GetConnectionString("JobTracker");
|
||||
if (string.IsNullOrWhiteSpace(cs))
|
||||
{
|
||||
cs = $"Data Source={paths.GetDbPath()}";
|
||||
}
|
||||
|
||||
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();
|
||||
builder.Services.AddDataProtection();
|
||||
builder.Services.AddHostedService<RulesHostedService>();
|
||||
builder.Services.AddHostedService<DailyExportHostedService>();
|
||||
|
||||
builder.Services.AddHttpClient("jobimport")
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All
|
||||
});
|
||||
|
||||
// Local summarizer service (FastAPI). Default URL can be overridden via configuration `Summarizer:BaseUrl`.
|
||||
builder.Services.AddHttpClient("summarizer", client =>
|
||||
{
|
||||
var 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.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>>();
|
||||
|
||||
// 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)
|
||||
{
|
||||
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
|
||||
);
|
||||
""");
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
|
||||
// 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;");
|
||||
}
|
||||
|
||||
// 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;");
|
||||
|
||||
// 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;");
|
||||
|
||||
// Ensure data folder exists before creating/opening SQLite files.
|
||||
Directory.CreateDirectory(paths.DataRoot);
|
||||
|
||||
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)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE Companies SET OwnerUserId=$uid WHERE OwnerUserId IS NULL;
|
||||
UPDATE JobApplications SET OwnerUserId=$uid WHERE OwnerUserId IS NULL;
|
||||
""";
|
||||
var p = cmd.CreateParameter();
|
||||
p.ParameterName = "$uid";
|
||||
p.Value = admin.Id;
|
||||
cmd.Parameters.Add(p);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.UseCors("AllowReact");
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
Reference in New Issue
Block a user