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
@@ -0,0 +1,232 @@
using System.Security.Claims;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Stripe;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/billing")]
public sealed class BillingController : ControllerBase
{
private const string UserMetadataKey = "jobtracker_user_id";
private readonly IConfiguration _configuration;
private readonly UserManager<ApplicationUser> _users;
private readonly RoleManager<IdentityRole> _roles;
private readonly ILogger<BillingController> _logger;
public BillingController(
IConfiguration configuration,
UserManager<ApplicationUser> users,
RoleManager<IdentityRole> roles,
ILogger<BillingController> logger)
{
_configuration = configuration;
_users = users;
_roles = roles;
_logger = logger;
}
public sealed record BillingRedirectDto(string Url);
public sealed record BillingStatusDto(bool Enabled, bool CanCheckout, bool CanManage);
[HttpGet("status")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<BillingStatusDto>> Status(CancellationToken cancellationToken)
{
var enabled = TryGetConfiguration(out _, out _, out _, out _);
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var user = userId is null ? null : await _users.FindByIdAsync(userId);
if (user is null) return Unauthorized();
var entitlements = AccountPlans.ForRoles(await _users.GetRolesAsync(user));
return Ok(new BillingStatusDto(
enabled,
enabled && !entitlements.AdvancedAi,
enabled && !string.IsNullOrWhiteSpace(user.StripeCustomerId)));
}
[HttpPost("checkout")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<BillingRedirectDto>> Checkout(CancellationToken cancellationToken)
{
if (!TryGetConfiguration(out var secretKey, out var premiumPrice, out _, out var publicBaseUrl))
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Billing is not configured.");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var user = userId is null ? null : await _users.FindByIdAsync(userId);
if (user is null) return Unauthorized();
var currentRoles = await _users.GetRolesAsync(user);
if (AccountPlans.ForRoles(currentRoles).AdvancedAi)
return Conflict("This account already has Premium access.");
var metadata = new Dictionary<string, string> { [UserMetadataKey] = user.Id };
var options = new Stripe.Checkout.SessionCreateOptions
{
Mode = "subscription",
SuccessUrl = $"{publicBaseUrl}/settings?billing=success",
CancelUrl = $"{publicBaseUrl}/settings?billing=cancelled",
ClientReferenceId = user.Id,
Customer = user.StripeCustomerId,
CustomerEmail = string.IsNullOrWhiteSpace(user.StripeCustomerId) ? user.Email : null,
Metadata = metadata,
SubscriptionData = new Stripe.Checkout.SessionSubscriptionDataOptions { Metadata = metadata },
LineItems = new List<Stripe.Checkout.SessionLineItemOptions>
{
new() { Price = premiumPrice, Quantity = 1 },
},
};
try
{
var session = await new Stripe.Checkout.SessionService(new StripeClient(secretKey))
.CreateAsync(options, cancellationToken: cancellationToken);
if (string.IsNullOrWhiteSpace(session.Url))
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Stripe did not return a checkout URL.");
return Ok(new BillingRedirectDto(session.Url));
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe checkout creation failed for user {UserId}", user.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Billing checkout is temporarily unavailable.");
}
}
[HttpPost("portal")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<BillingRedirectDto>> Portal(CancellationToken cancellationToken)
{
if (!TryGetConfiguration(out var secretKey, out _, out _, out var publicBaseUrl))
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Billing is not configured.");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var user = userId is null ? null : await _users.FindByIdAsync(userId);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(user.StripeCustomerId)) return NotFound("No Stripe customer exists for this account.");
try
{
var session = await new Stripe.BillingPortal.SessionService(new StripeClient(secretKey))
.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
{
Customer = user.StripeCustomerId,
ReturnUrl = $"{publicBaseUrl}/settings",
}, cancellationToken: cancellationToken);
return Ok(new BillingRedirectDto(session.Url));
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe billing portal creation failed for user {UserId}", user.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Billing management is temporarily unavailable.");
}
}
[HttpPost("webhook")]
[AllowAnonymous]
[RequestSizeLimit(1_000_000)]
public async Task<IActionResult> Webhook(CancellationToken cancellationToken)
{
if (!TryGetConfiguration(out var secretKey, out var premiumPrice, out var webhookSecret, out _))
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Billing is not configured.");
string json;
using (var reader = new StreamReader(Request.Body))
json = await reader.ReadToEndAsync(cancellationToken);
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"].ToString(), webhookSecret);
}
catch (StripeException ex)
{
_logger.LogWarning(ex, "Rejected a Stripe webhook with an invalid signature");
return BadRequest("Invalid Stripe signature.");
}
if (stripeEvent.Type is not (EventTypes.CustomerSubscriptionCreated
or EventTypes.CustomerSubscriptionUpdated
or EventTypes.CustomerSubscriptionDeleted))
return Ok();
if (stripeEvent.Data.Object is not Subscription eventSubscription)
return BadRequest("Stripe subscription payload was missing.");
Subscription subscription;
try
{
// Stripe does not guarantee webhook delivery order. Re-read the subscription so a late
// event cannot restore access after a newer cancellation or payment failure.
subscription = await new SubscriptionService(new StripeClient(secretKey))
.GetAsync(eventSubscription.Id, cancellationToken: cancellationToken);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Could not refresh Stripe subscription {SubscriptionId}", eventSubscription.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Could not verify the current subscription state.");
}
if (subscription.Items?.Data?.Any(item => item.Price?.Id == premiumPrice) != true)
{
_logger.LogWarning("Ignoring Stripe subscription {SubscriptionId} because it does not contain the configured Premium price", subscription.Id);
return Ok();
}
if (!subscription.Metadata.TryGetValue(UserMetadataKey, out var userId) || string.IsNullOrWhiteSpace(userId))
{
_logger.LogWarning("Ignoring Stripe subscription {SubscriptionId} because Jobbjakt user metadata is missing", subscription.Id);
return Ok();
}
var user = await _users.FindByIdAsync(userId);
if (user is null)
{
_logger.LogWarning("Ignoring Stripe subscription {SubscriptionId} because user {UserId} no longer exists", subscription.Id, userId);
return Ok();
}
user.StripeCustomerId = subscription.CustomerId;
user.StripeSubscriptionId = subscription.Id;
user.StripeSubscriptionStatus = subscription.Status;
user.StripeLastEventCreatedUtc = stripeEvent.Created;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
return Problem(statusCode: StatusCodes.Status500InternalServerError, title: "Could not persist billing state.");
const string premiumRole = "Premium";
if (!await _roles.RoleExistsAsync(premiumRole))
{
var roleResult = await _roles.CreateAsync(new IdentityRole(premiumRole));
if (!roleResult.Succeeded)
return Problem(statusCode: StatusCodes.Status500InternalServerError, title: "Could not provision the Premium role.");
}
var hasRole = await _users.IsInRoleAsync(user, premiumRole);
var shouldHaveRole = AccountPlans.IsPremiumSubscriptionStatus(subscription.Status);
var roleUpdate = shouldHaveRole && !hasRole
? await _users.AddToRoleAsync(user, premiumRole)
: !shouldHaveRole && hasRole
? await _users.RemoveFromRoleAsync(user, premiumRole)
: IdentityResult.Success;
if (!roleUpdate.Succeeded)
return Problem(statusCode: StatusCodes.Status500InternalServerError, title: "Could not update Premium access.");
return Ok();
}
private bool TryGetConfiguration(out string secretKey, out string premiumPrice, out string webhookSecret, out string publicBaseUrl)
{
secretKey = (_configuration["Stripe:SecretKey"] ?? string.Empty).Trim();
premiumPrice = (_configuration["Stripe:PricePremium"] ?? string.Empty).Trim();
webhookSecret = (_configuration["Stripe:WebhookSecret"] ?? string.Empty).Trim();
publicBaseUrl = (_configuration["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
return secretKey.Length > 0 && premiumPrice.Length > 0 && webhookSecret.Length > 0
&& Uri.TryCreate(publicBaseUrl, UriKind.Absolute, out var uri)
&& uri.Scheme is "http" or "https";
}
}
@@ -1,4 +1,5 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
namespace JobTrackerApi.Controllers
{
@@ -232,7 +233,8 @@ namespace JobTrackerApi.Controllers
List<string> MatchedKeywords,
List<string> MissingKeywords,
List<MatchSectionCoverageDto> SectionCoverage,
bool HasEnoughSignal);
bool HasEnoughSignal,
IReadOnlyList<LearningRecommendationDto> LearningRecommendations);
public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total);
}
@@ -567,9 +567,6 @@ Canonical profile:
{
lastMsg.TryGetValue(j.Id, out var lm);
var d = RulesEngine.Evaluate(settings, j, now, lm);
// Use persisted short summary when available to avoid repeated model calls.
var shortSummary = j.ShortSummary;
var summary = shortSummary; // list endpoints return the short summary only
dtoItems.Add(BuildJobApplicationDto(j, d));
}
@@ -599,8 +596,6 @@ Canonical profile:
{
lastMsg.TryGetValue(j.Id, out var lm);
var d = RulesEngine.Evaluate(settings, j, now, lm);
var shortSummary = j.ShortSummary;
var summary = shortSummary;
dtos.Add(BuildJobApplicationDto(j, d));
}
@@ -745,7 +740,7 @@ Canonical profile:
}
[HttpPost]
public async Task<ActionResult<JobApplication>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
public async Task<ActionResult<JobApplicationDto>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
{
var userId = CurrentUserId;
var title = (request.JobTitle ?? "").Trim();
@@ -825,7 +820,9 @@ Canonical profile:
.Include(j => j.Company)
.FirstAsync(j => j.Id == job.Id, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
var settings = await GetCachedRuleSettingsAsync(cancellationToken);
var followUp = RulesEngine.Evaluate(settings, created, DateTime.Now, lastMessageAt: null);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, BuildJobApplicationDto(created, followUp));
}
[HttpPut("{id:int}")]
@@ -1432,6 +1429,8 @@ Canonical profile:
}
var result = _matchService.Evaluate(job.JobTitle, jobText, cvSections);
var learningRecommendations = await _checklist.SyncLearningRecommendationsAsync(
userId, id, result.MissingKeywords, cancellationToken);
return Ok(new MatchScoreDto(
Score: result.Score,
@@ -1441,7 +1440,8 @@ Canonical profile:
MatchedKeywords: result.MatchedKeywords.ToList(),
MissingKeywords: result.MissingKeywords.ToList(),
SectionCoverage: result.SectionCoverage.Select(s => new MatchSectionCoverageDto(s.Section, s.Matched, s.Total)).ToList(),
HasEnoughSignal: result.HasEnoughSignal));
HasEnoughSignal: result.HasEnoughSignal,
LearningRecommendations: learningRecommendations));
}
[HttpGet("{id:int}/candidate-fit")]
@@ -315,7 +315,7 @@ public sealed partial class ProfileCvController : ControllerBase
[NonAction]
public async Task ProcessQueuedRunAsync(int runId, CancellationToken cancellationToken)
{
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == runId, cancellationToken);
var run = await _db.CvExtractionRuns.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == runId, cancellationToken);
if (run is null) return;
var user = await _users.FindByIdAsync(run.OwnerUserId);
if (user is null)
@@ -367,7 +367,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
case "reprocess":
{
var artifact = await _db.CvUploadArtifacts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken);
var artifact = await _db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken);
if (artifact is null) throw new InvalidOperationException("Upload a CV before reprocessing it.");
if (string.IsNullOrWhiteSpace(artifact.StoragePath) || !System.IO.File.Exists(artifact.StoragePath))
{
@@ -416,14 +416,46 @@ public sealed partial class ProfileCvController : ControllerBase
private async Task PruneExtractionRunsAsync(string ownerUserId, CancellationToken cancellationToken)
{
var expired = await _db.CvExtractionRuns
var expired = await _db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && x.Status != "queued" && x.Status != "running")
.OrderByDescending(x => x.StartedAtUtc)
.Skip(ExtractionRunRetentionCount)
.ToListAsync(cancellationToken);
if (expired.Count == 0) return;
_db.CvExtractionRuns.RemoveRange(expired);
if (expired.Count > 0)
{
_db.CvExtractionRuns.RemoveRange(expired);
await _db.SaveChangesAsync(cancellationToken);
}
var referencedArtifactIds = await _db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && x.ArtifactId != null)
.Select(x => x.ArtifactId!.Value)
.Distinct()
.ToListAsync(cancellationToken);
var user = await _users.FindByIdAsync(ownerUserId);
if (user?.CurrentCvUploadArtifactId is int currentArtifactId)
{
referencedArtifactIds.Add(currentArtifactId);
}
var orphanedArtifacts = await _db.CvUploadArtifacts.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && !referencedArtifactIds.Contains(x.Id))
.ToListAsync(cancellationToken);
if (orphanedArtifacts.Count == 0) return;
_db.CvUploadArtifacts.RemoveRange(orphanedArtifacts);
await _db.SaveChangesAsync(cancellationToken);
foreach (var artifact in orphanedArtifacts)
{
try
{
if (!string.IsNullOrWhiteSpace(artifact.StoragePath)) System.IO.File.Delete(artifact.StoragePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete unreferenced CV artifact {ArtifactId} at {Path}", artifact.Id, artifact.StoragePath);
}
}
}
private async Task SendRunCompletionEmailAsync(ApplicationUser user, CvExtractionRun run, bool success, CancellationToken cancellationToken)
@@ -3,6 +3,7 @@ using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace JobTrackerApi.Controllers;
@@ -15,11 +16,13 @@ public sealed class PublicCvController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly ICvVariantService _variants;
private readonly ICvPdfExporter _pdf;
public PublicCvController(UserManager<ApplicationUser> users, ICvVariantService variants)
public PublicCvController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf)
{
_users = users;
_variants = variants;
_pdf = pdf;
}
[HttpGet("{slug}")]
@@ -37,6 +40,22 @@ public sealed class PublicCvController : ControllerBase
return Ok(new { html = result.Value.render.Html, name = person.FallbackName });
}
[HttpGet("{slug}/pdf")]
[EnableRateLimiting("public-pdf")]
public async Task<IActionResult> DownloadPdf(string slug, CancellationToken ct)
{
var ownerId = await _variants.GetPublicOwnerAsync(slug, ct);
if (ownerId is null) return NotFound();
var person = Person(await _users.FindByIdAsync(ownerId));
var result = await _variants.RenderPublicAsync(slug, person, ct);
if (result is null) return NotFound();
var render = result.Value.render;
var artifact = await _pdf.ExportAsync(new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct);
return File(artifact.Bytes, "application/pdf", artifact.FileName);
}
private static CvRenderPerson Person(ApplicationUser? user)
{
if (user is null) return new CvRenderPerson("Candidate", null);