feat: complete release readiness work

- consolidate API ownership and remove dead vendor code

- add Stripe billing, learning paths, and public CV hardening

- add migration, recovery, security, audit, and browser gates
This commit is contained in:
cesnimda
2026-07-31 16:54:16 +02:00
parent a23c3dfc97
commit ce76046a29
1634 changed files with 6889 additions and 135429 deletions
@@ -1,6 +1,8 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace JobTrackerApi.Services;
@@ -28,6 +30,8 @@ public sealed record ChecklistProgressDto(int Total, int Completed, int Dismisse
public sealed record ChecklistDto(IReadOnlyList<ChecklistItemDto> Items, ChecklistProgressDto Progress);
public sealed record LearningRecommendationDto(int Id, string Keyword, string Status);
public sealed record ChecklistItemInput(string? Title, string? Description, string? Category, string? Status, string? Section);
// The signals a checklist item can auto-complete from. Computed once per read.
@@ -86,10 +90,13 @@ public interface IApplicationChecklistService
Task<ChecklistItemDto?> UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, ChecklistItemInput input, CancellationToken ct);
Task<bool> DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct);
Task<ChecklistDto?> ReorderAsync(string ownerUserId, int jobApplicationId, IReadOnlyList<int> orderedIds, CancellationToken ct);
Task<IReadOnlyList<LearningRecommendationDto>> SyncLearningRecommendationsAsync(
string ownerUserId, int jobApplicationId, IReadOnlyList<string> missingKeywords, CancellationToken ct);
}
public sealed class ApplicationChecklistService : IApplicationChecklistService
{
private const string LearningKeyPrefix = "learning:";
// The default system checklist. Stable keys — renaming a title must never orphan a user's item.
private sealed record Template(string Key, string Title, string Description, string Category, string? Signal, string? Section);
@@ -246,6 +253,83 @@ public sealed class ApplicationChecklistService : IApplicationChecklistService
return Project(items);
}
public async Task<IReadOnlyList<LearningRecommendationDto>> SyncLearningRecommendationsAsync(
string ownerUserId, int jobApplicationId, IReadOnlyList<string> missingKeywords, CancellationToken ct)
{
if (!await _db.JobApplications.AsNoTracking()
.AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct))
{
return [];
}
var keywords = missingKeywords
.Where(keyword => !string.IsNullOrWhiteSpace(keyword))
.Select(keyword => keyword.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var items = await _db.ApplicationChecklistItems
.Where(item => item.OwnerUserId == ownerUserId
&& item.JobApplicationId == jobApplicationId
&& item.SystemKey != null
&& item.SystemKey.StartsWith(LearningKeyPrefix))
.ToListAsync(ct);
var byKey = items.ToDictionary(item => item.SystemKey!, StringComparer.OrdinalIgnoreCase);
var activeKeys = keywords.Select(LearningKey).ToHashSet(StringComparer.OrdinalIgnoreCase);
var changed = false;
foreach (var keyword in keywords)
{
var key = LearningKey(keyword);
if (!byKey.TryGetValue(key, out var item))
{
item = new ApplicationChecklistItem
{
OwnerUserId = ownerUserId,
JobApplicationId = jobApplicationId,
SystemKey = key,
Title = keyword,
Description = $"Build or verify evidence for {keyword} before claiming it in an application.",
Category = ChecklistCategories.Custom,
Status = ChecklistStatuses.Pending,
Section = "match",
SortOrder = items.Count,
IsSystemGenerated = true,
};
_db.ApplicationChecklistItems.Add(item);
items.Add(item);
byKey[key] = item;
changed = true;
}
else if (item.Status == ChecklistStatuses.Done && item.IsAutoCompleted)
{
item.Status = ChecklistStatuses.Pending;
item.IsAutoCompleted = false;
item.CompletedAt = null;
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
changed = true;
}
}
foreach (var item in items.Where(item => !activeKeys.Contains(item.SystemKey!)
&& item.Status == ChecklistStatuses.Pending))
{
item.Status = ChecklistStatuses.Done;
item.IsAutoCompleted = true;
item.CompletedAt = DateTimeOffset.UtcNow;
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
changed = true;
}
if (changed) await _db.SaveChangesAsync(ct);
return keywords.Select(keyword => byKey[LearningKey(keyword)])
.Select(item => new LearningRecommendationDto(item.Id, item.Title, item.Status))
.ToList();
}
private static string LearningKey(string keyword) => LearningKeyPrefix
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(keyword.Trim().ToLowerInvariant())))[..40];
// The next unfinished step, by category priority then the user's own ordering. This is what
// ApplicationWorkspaceService surfaces as "what do I do next" — one source, not a parallel ruleset.
public static ChecklistItemDto? NextPending(ChecklistDto checklist) =>
+29 -3
View File
@@ -1,5 +1,7 @@
using System.Threading.Channels;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
@@ -50,13 +52,13 @@ public sealed class CvProcessingHostedService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await ProcessInterruptedRunsAsync(stoppingToken);
await foreach (var runId in _queue.DequeueAllAsync(stoppingToken))
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var controller = scope.ServiceProvider.GetRequiredService<ProfileCvController>();
await controller.ProcessQueuedRunAsync(runId, stoppingToken);
await ProcessRunAsync(runId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -68,4 +70,28 @@ public sealed class CvProcessingHostedService : BackgroundService
}
}
}
private async Task ProcessInterruptedRunsAsync(CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var interruptedRuns = await db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.Status == "queued" || x.Status == "running")
.Select(x => new { x.Id, x.StartedAtUtc })
.ToListAsync(cancellationToken);
// ponytail: single-instance recovery; use row leasing if multiple workers are ever deployed.
// SQLite cannot ORDER BY DateTimeOffset, so the small interrupted-work set is ordered locally.
foreach (var run in interruptedRuns.OrderBy(x => x.StartedAtUtc))
{
await ProcessRunAsync(run.Id, cancellationToken);
}
}
private async Task ProcessRunAsync(int runId, CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var controller = scope.ServiceProvider.GetRequiredService<ProfileCvController>();
await controller.ProcessQueuedRunAsync(runId, cancellationToken);
}
}
+1 -1
View File
@@ -148,7 +148,7 @@ namespace JobTrackerApi.Services
var frequencies = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var token in Tokenize(jobText))
{
if (token.Length < 3 || StopWords.Contains(token) || IsNumeric(token)) continue;
if (token.Length is < 3 or > 64 || StopWords.Contains(token) || IsNumeric(token)) continue;
frequencies[token] = frequencies.TryGetValue(token, out var c) ? c + 1 : 1;
}
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace JobTrackerApi.Services;
@@ -26,16 +27,19 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
private readonly AppPaths _paths;
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
private readonly int _retentionDays;
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger)
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger, IConfiguration configuration)
{
_paths = paths;
_logger = logger;
_retentionDays = Math.Clamp(configuration.GetValue("CvExports:RetainDays", 30), 1, 365);
}
public async Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
PruneExpiredExports(DateOnly.FromDateTime(now.UtcDateTime).AddDays(-_retentionDays));
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
Directory.CreateDirectory(folder);
@@ -46,10 +50,8 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
var tempRoot = Path.Combine(Path.GetTempPath(), "jobtracker-cv-pdf", Guid.NewGuid().ToString("n"));
var htmlPath = Path.Combine(tempRoot, "document.html");
var userDataDir = Path.Combine(tempRoot, "profile");
Directory.CreateDirectory(tempRoot);
Directory.CreateDirectory(userDataDir);
try
{
@@ -61,10 +63,12 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
throw new InvalidOperationException("CV PDF export is unavailable. Install Chromium/Google Chrome or set CV_PDF_BROWSER_PATH.");
}
var arguments = BuildArguments(userDataDir, storagePath, htmlPath);
var startInfo = new ProcessStartInfo();
startInfo.FileName = browserPath;
startInfo.Arguments = arguments;
foreach (var argument in BuildArguments(storagePath, htmlPath))
{
startInfo.ArgumentList.Add(argument);
}
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
@@ -73,7 +77,14 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
await process.WaitForExitAsync(cancellationToken);
try
{
await process.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(60), cancellationToken);
}
finally
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
}
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
@@ -102,9 +113,27 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
}
}
private static string BuildArguments(string userDataDir, string storagePath, string htmlPath)
private void PruneExpiredExports(DateOnly cutoff)
{
var parts = new List<string>
foreach (var directory in Directory.EnumerateDirectories(_paths.CvExportsRoot))
{
var name = Path.GetFileName(directory);
if (!DateOnly.TryParseExact(name, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) || date >= cutoff) continue;
try
{
Directory.Delete(directory, recursive: true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not prune expired CV export directory {Directory}", directory);
}
}
}
private static IReadOnlyList<string> BuildArguments(string storagePath, string htmlPath)
{
return new[]
{
"--headless=new",
"--disable-gpu",
@@ -112,12 +141,9 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
"--disable-dev-shm-usage",
"--allow-file-access-from-files",
"--enable-local-file-accesses",
"--user-data-dir=" + Quote(userDataDir),
"--print-to-pdf=" + Quote(storagePath),
Quote(htmlPath)
$"--print-to-pdf={storagePath}",
new Uri(htmlPath).AbsoluteUri
};
return string.Join(' ', parts);
}
private static string? ResolveBrowserPath()
@@ -171,8 +197,4 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
}
}
private static string Quote(string value)
{
return '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"';
}
}
@@ -4,6 +4,8 @@ using JobTrackerApi.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace JobTrackerApi.Services;
@@ -529,6 +531,10 @@ public static class StartupInitializationExtensions
EnsureColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpSecretEncrypted TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpPendingSecretEncrypted TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE AspNetUsers ADD COLUMN TotpEnabledAtUtc TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeCustomerId", "ALTER TABLE AspNetUsers ADD COLUMN StripeCustomerId TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionId TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionStatus TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
static void EnsureUserRuleSettingsTable(DbConnection c)
{
@@ -1115,12 +1121,11 @@ public static class StartupInitializationExtensions
EnsureCoverLetterVersionsTable(conn);
EnsureInterviewPrepItemsTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
// Once the base app tables exist, provision this reconciler-owned schema set and
// stamp its historical migration before later migrations rebuild JobApplications.
var isLegacy =
HasMigration(conn, "20260310174114_AddCorrespondence") &&
!HasMigration(conn, legacyMigrationId) &&
(HasColumn(conn, "Companies", "Source") || HasColumn(conn, "JobApplications", "IsDeleted"));
!HasMigration(conn, legacyMigrationId);
if (isLegacy)
{
@@ -1427,6 +1432,10 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpSecretEncrypted` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpPendingSecretEncrypted` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpEnabledAtUtc` datetime NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeCustomerId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeCustomerId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionStatus` varchar(64) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
// used to create it too, which made a clean install fail with "Table 'RuleSettings'
@@ -1997,13 +2006,19 @@ public static class StartupInitializationExtensions
// table is skipped here (the parent does not exist yet) and picked up in pass 3.
ReconcileSchema();
// 2. Migrations create every migration-owned table. On a brand-new database this is what
// actually builds the schema; the pass above found nothing to reconcile.
// 2. Apply one migration at a time, reconciling after each. Some historical migrations
// rebuild tables using columns owned by the reconciler, so a fresh database needs
// those columns added after the base table appears and before a later migration reads it.
try
{
using var migrationScope = app.Services.CreateScope();
var migrationDb = migrationScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
migrationDb.Database.Migrate();
var migrator = migrationDb.Database.GetService<IMigrator>();
while (migrationDb.Database.GetPendingMigrations().FirstOrDefault() is { } migration)
{
migrator.Migrate(migration);
ReconcileSchema();
}
}
catch (Exception ex)
{