Files
jobtrackingapp/JobTrackerApi/Program.cs
T
cesnimda 3d74baef78
CI and Deploy / test (push) Failing after 3m54s
CI and Deploy / deploy (push) Has been skipped
feat(workspace): interview and follow-up workflow
Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase.

Interview preparation gets a durable, user-owned store. There were already two
per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are
caches that regenerate when their context signature changes — anything a user
typed into them would eventually be overwritten. InterviewPrepItem is the side
nothing regenerates, covering company research, technical notes, behavioural
answers, STAR examples and the user's own questions in one table, because those
categories differ only by label and adding one must not need a migration. Each
item records whether the user wrote it or accepted a suggestion, and an
IsPrepared flag makes the section double as the preparation checklist.

Generation stays in the existing AiWorkspaceService "interview" module, appended
to AiInteraction as before. A suggestion is history until the user adds it as a
prep item; opening the section generates nothing.

Follow-up reuses what exists rather than adding a tracker. The date is
JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted
service already act on, so reminders keep working with no new wiring. The task
stays an ApplicationChecklistItem in the follow-up category — the section counts
open tasks without owning them. The record is a FollowUpSet JobEvent, the same
type the rest of the app emits.

Communication is untouched: Correspondence already owns recruiter contacts,
history and notes, and the workspace already mounted it.

The timeline interpreter learned five more types — InterviewScheduled,
InterviewCompleted and OfferReceived as milestones, FollowUpCreated and
FollowUpCompleted as routine, deliberately outside the milestone spine so it
stays a summary of what actually happened. JobEvent remains the history source.

InterviewPrepItems is reconciler-owned with a no-op migration, guarded on
JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary
key, varchar owner and title, tinyint flag, datetime(6), composite index inside
the key limit.

371 backend tests, 128 frontend tests, Release build and the production build all
pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:01:33 +02:00

530 lines
21 KiB
C#

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using JobTrackerApi.Controllers;
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.AspNetCore.RateLimiting;
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 System.Threading.RateLimiting;
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<ICvProcessingQueue, CvProcessingQueue>();
builder.Services.AddTransient<ProfileCvController>();
builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
builder.Services.AddScoped<IApplicationChecklistService, ApplicationChecklistService>();
builder.Services.AddScoped<IApplicationTimelineService, ApplicationTimelineService>();
builder.Services.AddScoped<IApplicationIntelligenceService, ApplicationIntelligenceService>();
builder.Services.AddScoped<IApplicationAssetsService, ApplicationAssetsService>();
builder.Services.AddScoped<IInterviewPrepService, InterviewPrepService>();
builder.Services.AddSingleton<AppPaths>();
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
// 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)), mysql =>
{
mysql.MigrationsAssembly("JobTrackerApi");
});
}
else
{
options.UseSqlite(cs, sqlite =>
{
sqlite.MigrationsAssembly("JobTrackerApi");
});
}
// 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);
w.Ignore(CoreEventId.PossibleIncorrectRequiredNavigationWithQueryFilterInteractionWarning);
});
});
// 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.SetIsOriginAllowed(_ => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
}
else
{
policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray())
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
}
});
});
// Add controllers
builder.Services.AddControllers();
builder.Services.AddOpenApi();
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.AddSingleton<IDatabaseBackupRunner, SqliteDatabaseBackupRunner>();
builder.Services.AddHostedService<DatabaseBackupHostedService>();
builder.Services.AddHostedService<RulesHostedService>();
builder.Services.AddHostedService<FollowUpReminderHostedService>();
builder.Services.AddHostedService<DailyExportHostedService>();
builder.Services.AddHostedService<JobEnrichmentHostedService>();
builder.Services.AddHostedService<SummarizerProbeHostedService>();
builder.Services.AddHostedService<CvProcessingHostedService>();
builder.Services.AddHttpClient("jobimport")
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
AllowAutoRedirect = false
});
// Local AI service (FastAPI). Supports summarization and OCR/text extraction.
// Every caller goes through this named client, so the shared-secret header is set once here.
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);
var serviceToken = builder.Configuration["Ai:ServiceToken"];
if (!string.IsNullOrWhiteSpace(serviceToken))
{
client.DefaultRequestHeaders.Add("X-Ai-Service-Token", serviceToken);
}
});
builder.Services.AddMemoryCache();
builder.Services.AddScoped<AnalyticsService>();
builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
builder.Services.AddSingleton<IJobCvMatchService, JobCvMatchService>();
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
builder.Services.AddSingleton<ICvAiNormalizer, CvAiNormalizer>();
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
builder.Services.AddSingleton<IMicrosoftTokenValidator, MicrosoftTokenValidator>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
builder.Services.AddScoped<IMicrosoftGraphOAuthService, MicrosoftGraphOAuthService>();
builder.Services.AddScoped<IImapService, ImapService>();
// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph + IMAP today; manual next).
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.MicrosoftGraphProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.ImapProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
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;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
})
.AddRoles<IdentityRole>()
.AddEntityFrameworkStores<JobTrackerContext>()
.AddSignInManager();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddSingleton<ITwoFactorPendingTokenService, TwoFactorPendingTokenService>();
builder.Services.AddSingleton<UniversalJobParser>();
builder.Services.AddSingleton<IHostAddressResolver, DnsHostAddressResolver>();
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 microsoftClientId = (builder.Configuration["Auth:MicrosoftClientId"] ?? "").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) && string.IsNullOrWhiteSpace(microsoftClientId))
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 ?? "";
if (!string.IsNullOrWhiteSpace(googleClientId) && iss is "accounts.google.com" or "https://accounts.google.com")
return "google";
if (!string.IsNullOrWhiteSpace(microsoftClientId) && iss.StartsWith("https://login.microsoftonline.com/", StringComparison.OrdinalIgnoreCase))
return "microsoft";
return "local";
}
catch
{
return "local";
}
};
})
.AddJwtBearer("local", options =>
{
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
if (!string.IsNullOrWhiteSpace(context.Token))
{
return Task.CompletedTask;
}
if (context.Request.Cookies.TryGetValue(AuthSessionOptions.SessionCookieName, out var cookieToken) && !string.IsNullOrWhiteSpace(cookieToken))
{
context.Token = cookieToken;
}
return Task.CompletedTask;
},
OnTokenValidated = async context =>
{
var userId = LocalAuthIdentity.GetRequiredUserId(context.Principal);
if (userId is null)
{
context.Fail("Local tokens must include a subject/nameidentifier claim.");
return;
}
// Resolve a fresh scoped JobTrackerContext for this one lookup -- OnTokenValidated
// runs outside the request's normal DI-constructor scope, so RequestServices (the
// per-request scope) must be used directly rather than a captured/singleton one.
// Fail closed if the session row is missing/revoked/expired (including tokens
// with no "sid" claim at all -- see LocalSessionValidator for why: every JWT
// issued going forward carries one, so a token without it is either pre-deploy
// (forces a single re-login for anyone already signed in when this ships --
// acceptable, same additive-forward cost the 2FA/trusted-device features on this
// branch already paid) or forged, and either way isn't proof of a live session.
var db = context.HttpContext.RequestServices.GetRequiredService<JobTrackerContext>();
if (!await LocalSessionValidator.IsValidAsync(db, context.Principal, DateTimeOffset.UtcNow))
{
context.Fail("Session has been revoked or expired.");
}
}
};
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,
};
});
}
if (!string.IsNullOrWhiteSpace(microsoftClientId))
{
builder.Services.AddAuthentication().AddJwtBearer("microsoft", options =>
{
// Validate Microsoft (Entra ID / personal account) ID tokens as bearer tokens.
// "common" authority + ValidateIssuer=false: multi-tenant issuer varies per tenant id.
options.Authority = "https://login.microsoftonline.com/common/v2.0";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = true,
ValidAudience = microsoftClientId,
ValidateLifetime = true,
};
});
}
builder.Services.AddAuthorization(options =>
{
if (requireAuth)
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
}
});
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("auth-login", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"login:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 10,
Window = TimeSpan.FromMinutes(5),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
}));
options.AddPolicy("auth-email", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"email:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(15),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
}));
// Brute-forcing a 6-digit TOTP code (1e6 space) is far more feasible than a password, so
// this gets a tighter window than auth-login.
options.AddPolicy("auth-2fa-challenge", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"2fa:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(5),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
}));
});
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;
}
});
await app.InitializeJobTrackerAsync();
app.UseCors("AllowReact");
app.UseRateLimiter();
app.Use(async (ctx, next) =>
{
if (HttpMethods.IsGet(ctx.Request.Method) || HttpMethods.IsHead(ctx.Request.Method) || HttpMethods.IsOptions(ctx.Request.Method) || HttpMethods.IsTrace(ctx.Request.Method))
{
await next();
return;
}
if (!ctx.Request.Cookies.ContainsKey(AuthSessionOptions.SessionCookieName))
{
await next();
return;
}
if (ctx.Request.Path.StartsWithSegments("/api/auth/login")
|| ctx.Request.Path.StartsWithSegments("/api/auth/register")
|| ctx.Request.Path.StartsWithSegments("/api/auth/google/exchange")
|| ctx.Request.Path.StartsWithSegments("/api/auth/request-password-reset")
|| ctx.Request.Path.StartsWithSegments("/api/auth/reset-password")
|| ctx.Request.Path.StartsWithSegments("/api/auth/csrf"))
{
await next();
return;
}
var csrfCookie = ctx.Request.Cookies[AuthSessionOptions.CsrfCookieName];
var csrfHeader = ctx.Request.Headers[AuthSessionOptions.CsrfHeaderName].ToString();
if (string.IsNullOrWhiteSpace(csrfCookie) || string.IsNullOrWhiteSpace(csrfHeader) || !string.Equals(csrfCookie, csrfHeader, StringComparison.Ordinal))
{
ctx.Response.StatusCode = StatusCodes.Status403Forbidden;
await ctx.Response.WriteAsync("CSRF validation failed.");
return;
}
await next();
});
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
// API schema for tooling/docs. Dev-only: not exposed in production deployments.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi().AllowAnonymous();
}
app.Run();