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:
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user