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