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) =>
|
||||
|
||||
Reference in New Issue
Block a user