Add freemium access economy and token store
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
using QuestionsApi.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class AccessCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void FreeEconomy_HasTheReleaseLimits()
|
||||
{
|
||||
Assert.Equal(20, AccessCatalog.FreeQuestionLimit);
|
||||
Assert.Equal(20, AccessCatalog.CreditEarningLimit);
|
||||
Assert.Equal(100, AccessCatalog.DayPassCreditCost);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("access_day_1", 100, AccessGrant.DayToken)]
|
||||
[InlineData("access_week_1", 500, AccessGrant.WeekToken)]
|
||||
[InlineData("access_month_1", 2000, AccessGrant.MonthToken)]
|
||||
[InlineData("exam_token_1", 199, AccessGrant.ExamToken)]
|
||||
[InlineData("qbank_reset_1", 499, AccessGrant.ResetToken)]
|
||||
public void Products_HaveServerControlledPrices(string sku, long cents, string grant)
|
||||
{
|
||||
var product = AccessCatalog.Products[sku];
|
||||
|
||||
Assert.Equal(cents, product.AmountCents);
|
||||
Assert.Equal("usd", product.Currency);
|
||||
Assert.Equal(grant, product.Grant);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/access")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class AccessController(
|
||||
AccessStore access,
|
||||
ExamSimulationStore simulations,
|
||||
RatingStore ratings,
|
||||
TimeProvider clock) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get() =>
|
||||
Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
|
||||
|
||||
[HttpPost("redeem-credits")]
|
||||
public async Task<IActionResult> RedeemCredits(RedeemCreditsRequest request)
|
||||
{
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
var status = await access.RedeemCreditsAsync(UserId(), request.OperationId.Trim());
|
||||
return status is null
|
||||
? Conflict(new { code = "insufficient_credits", message = "100 credits are required to redeem a day pass." })
|
||||
: Ok(status);
|
||||
}
|
||||
|
||||
[HttpPost("activate-token")]
|
||||
public async Task<IActionResult> ActivateToken(ActivateAccessTokenRequest request)
|
||||
{
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
if (request.Token.Trim().ToLowerInvariant() is not ("day" or "week" or "month"))
|
||||
return BadRequest("token must be day, week, or month");
|
||||
var status = await access.ActivateTokenAsync(UserId(), request.OperationId.Trim(), request.Token);
|
||||
return status is null
|
||||
? Conflict(new { code = "token_unavailable", message = "That access token is not available." })
|
||||
: Ok(status);
|
||||
}
|
||||
|
||||
[HttpPost("qbank-reset")]
|
||||
public async Task<IActionResult> ResetQBank(ResetQBankRequest request)
|
||||
{
|
||||
if (request.Confirmation != "RESET_QBANK") return BadRequest("Type RESET_QBANK to confirm.");
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
if (await simulations.FindActiveAsync(UserId()) is not null)
|
||||
return Conflict(new { code = "active_exam", message = "Finish or abandon the active exam simulation before resetting the QBank." });
|
||||
var cycle = await access.ApplyResetAsync(UserId(), request.OperationId.Trim());
|
||||
if (cycle is null)
|
||||
return Conflict(new { code = "reset_token_unavailable", message = "A QBank reset token is required." });
|
||||
await ratings.ResetUserAsync(UserId(), clock.GetUtcNow().UtcDateTime);
|
||||
return Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
|
||||
}
|
||||
|
||||
[HttpPost("steam-checkouts")]
|
||||
public IActionResult SteamCheckout() => Problem(
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Steam Wallet checkout is not configured",
|
||||
detail: "Steam purchases will be enabled after the Steamworks application and product catalog are configured.");
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? throw new UnauthorizedAccessException();
|
||||
|
||||
private static bool ValidOperationId(string value) =>
|
||||
!string.IsNullOrWhiteSpace(value) && value.Trim().Length <= 100;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/access")]
|
||||
public sealed class AccessPurchasesController(
|
||||
AccessPurchaseStore purchases,
|
||||
AccessStore access,
|
||||
IStripeAccessGateway stripe,
|
||||
IConfiguration configuration,
|
||||
ILogger<AccessPurchasesController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("web-checkouts")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> CreateCheckout(CreateAccessCheckoutRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!AccessCatalog.Products.TryGetValue(request.Sku.Trim(), out var product))
|
||||
return BadRequest("Unknown access product.");
|
||||
var purchase = await purchases.CreateAsync(UserId(), product);
|
||||
try
|
||||
{
|
||||
var checkout = await stripe.CreateCheckoutAsync(purchase, product, cancellationToken);
|
||||
await purchases.AttachSessionAsync(purchase.Id!, checkout.SessionId);
|
||||
return StatusCode(StatusCodes.Status201Created, new AccessCheckoutResponse
|
||||
{
|
||||
PurchaseId = purchase.Id!,
|
||||
CheckoutUrl = checkout.CheckoutUrl,
|
||||
Sku = product.Sku,
|
||||
AmountCents = product.AmountCents
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await purchases.MarkCheckoutFailedAsync(purchase.Id!);
|
||||
logger.LogError(exception, "Unable to create access checkout {PurchaseId}", purchase.Id);
|
||||
return Problem(statusCode: StatusCodes.Status502BadGateway,
|
||||
title: "Access checkout unavailable",
|
||||
detail: "The secure checkout could not be started. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("purchases/{id}")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> GetPurchase(string id) =>
|
||||
await purchases.GetOwnedAsync(id.Trim(), UserId()) is { } purchase
|
||||
? Ok(AccessPurchaseStore.ToStatus(purchase))
|
||||
: NotFound();
|
||||
|
||||
[HttpPost("stripe-webhook")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> StripeWebhook(CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookSecret = configuration["Stripe:AccessWebhookSecret"];
|
||||
if (string.IsNullOrWhiteSpace(webhookSecret))
|
||||
return Problem(statusCode: 503, title: "Stripe webhook is not configured");
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = await reader.ReadToEndAsync(cancellationToken);
|
||||
Event stripeEvent;
|
||||
try
|
||||
{
|
||||
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], webhookSecret);
|
||||
}
|
||||
catch (StripeException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Rejected access webhook with an invalid signature");
|
||||
return BadRequest("Invalid Stripe signature");
|
||||
}
|
||||
if (stripeEvent.Data.Object is not Session session) return Ok();
|
||||
var status = stripeEvent.Type switch
|
||||
{
|
||||
"checkout.session.completed" when session.PaymentStatus == "paid" => AccessPurchaseStatus.Paid,
|
||||
"checkout.session.async_payment_succeeded" => AccessPurchaseStatus.Paid,
|
||||
"checkout.session.async_payment_failed" => AccessPurchaseStatus.Failed,
|
||||
"checkout.session.expired" => AccessPurchaseStatus.Expired,
|
||||
_ => ""
|
||||
};
|
||||
if (status.Length == 0) return Ok();
|
||||
var purchaseId = session.ClientReferenceId;
|
||||
if (string.IsNullOrWhiteSpace(purchaseId) &&
|
||||
session.Metadata.TryGetValue("access_purchase_id", out var metadataId))
|
||||
purchaseId = metadataId;
|
||||
var purchase = await purchases.ApplyProviderStatusAsync(
|
||||
purchaseId ?? "", session.Id, session.AmountTotal ?? 0, session.Currency ?? "",
|
||||
status, session.PaymentIntentId ?? "", stripeEvent.Id);
|
||||
if (purchase is null) return Ok();
|
||||
if (status == AccessPurchaseStatus.Paid &&
|
||||
AccessCatalog.Products.TryGetValue(purchase.Sku, out var product))
|
||||
{
|
||||
await access.ApplyProductGrantAsync(purchase.UserId, product, $"purchase:{purchase.Id}");
|
||||
await purchases.MarkGrantAppliedAsync(purchase.Id!);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace QuestionsApi.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/exam-simulations")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations, AccessStore access) : ControllerBase
|
||||
{
|
||||
[HttpGet("readiness")]
|
||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||
@@ -17,12 +17,28 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var operationId = $"exam-create:{Guid.NewGuid():N}";
|
||||
if (!User.IsInRole("SUPER") && !await access.TryConsumeExamTokenAsync(UserId(), operationId))
|
||||
return StatusCode(StatusCodes.Status402PaymentRequired, new
|
||||
{
|
||||
code = "exam_token_required",
|
||||
message = "An exam simulation token is required."
|
||||
});
|
||||
try
|
||||
{
|
||||
var simulation = await simulations.CreateAsync(UserId());
|
||||
return Ok(await simulations.ToResponseAsync(simulation));
|
||||
}
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
|
||||
return Conflict(exception.Message);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("preview-results")]
|
||||
|
||||
@@ -19,6 +19,7 @@ public class QuestionsController : ControllerBase
|
||||
private readonly ServiceMetrics _metrics;
|
||||
private readonly QuestionBookmarkStore _bookmarks;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
private readonly AccessStore _access;
|
||||
|
||||
public QuestionsController(
|
||||
QuestionStore questions,
|
||||
@@ -28,7 +29,8 @@ public class QuestionsController : ControllerBase
|
||||
AnswerStatisticsStore statistics,
|
||||
ServiceMetrics metrics,
|
||||
QuestionBookmarkStore bookmarks,
|
||||
ReviewQueueStore reviewQueue)
|
||||
ReviewQueueStore reviewQueue,
|
||||
AccessStore access)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -38,6 +40,7 @@ public class QuestionsController : ControllerBase
|
||||
_metrics = metrics;
|
||||
_bookmarks = bookmarks;
|
||||
_reviewQueue = reviewQueue;
|
||||
_access = access;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -52,6 +55,13 @@ public class QuestionsController : ControllerBase
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
|
||||
{
|
||||
if (!await _access.CanLoadQuestionAsync(UserId(), User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted. Redeem or activate an access pass to continue.",
|
||||
access = await _access.GetStatusAsync(UserId())
|
||||
});
|
||||
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
|
||||
var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
|
||||
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
|
||||
@@ -117,6 +127,8 @@ public class QuestionsController : ControllerBase
|
||||
return BadRequest("questionExternalId required");
|
||||
if (string.IsNullOrWhiteSpace(req.ChoiceId))
|
||||
return BadRequest("choiceId required");
|
||||
if (string.IsNullOrWhiteSpace(req.CompletionId) || req.CompletionId.Trim().Length > 100)
|
||||
return BadRequest("completionId required and must be 100 characters or fewer");
|
||||
|
||||
var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
|
||||
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
|
||||
@@ -146,6 +158,15 @@ public class QuestionsController : ControllerBase
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
var completionId = req.CompletionId.Trim();
|
||||
if (!await _access.ClaimQuestionAsync(userId, completionId, question.ExternalId, User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted.",
|
||||
access = await _access.GetStatusAsync(userId)
|
||||
});
|
||||
var bankCycle = await _access.GetBankCycleAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
@@ -154,7 +175,9 @@ public class QuestionsController : ControllerBase
|
||||
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
||||
false,
|
||||
presentedChoiceIds,
|
||||
req.DurationSeconds);
|
||||
req.DurationSeconds,
|
||||
bankCycle,
|
||||
completionId);
|
||||
if (record.Inserted)
|
||||
{
|
||||
_metrics.AttemptCreated();
|
||||
@@ -172,6 +195,8 @@ public class QuestionsController : ControllerBase
|
||||
_metrics.AttemptDuplicate();
|
||||
}
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (record.Inserted && !User.IsInRole("SUPER"))
|
||||
await _access.TryAwardCreditAsync(userId, $"qbank-credit:{record.Attempt.Id}");
|
||||
|
||||
var ratingOwner = Guid.NewGuid().ToString("N");
|
||||
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
|
||||
@@ -204,7 +229,8 @@ public class QuestionsController : ControllerBase
|
||||
question,
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating
|
||||
Rating = rating,
|
||||
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,7 +278,7 @@ public class QuestionsController : ControllerBase
|
||||
|
||||
[HttpPost("{externalId}/skip")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Skip(string externalId)
|
||||
public async Task<IActionResult> Skip(string externalId, [FromQuery] string completionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(externalId))
|
||||
return BadRequest("externalId required");
|
||||
@@ -264,13 +290,28 @@ public class QuestionsController : ControllerBase
|
||||
var userId = CurrentUserId();
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(completionId) || completionId.Trim().Length > 100)
|
||||
return BadRequest("completionId required and must be 100 characters or fewer");
|
||||
completionId = completionId.Trim();
|
||||
if (!await _access.ClaimQuestionAsync(userId, completionId, question.ExternalId, User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted.",
|
||||
access = await _access.GetStatusAsync(userId)
|
||||
});
|
||||
var bankCycle = await _access.GetBankCycleAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
userId,
|
||||
"",
|
||||
false,
|
||||
true);
|
||||
true,
|
||||
null,
|
||||
0,
|
||||
bankCycle,
|
||||
completionId);
|
||||
if (record.Inserted)
|
||||
_metrics.AttemptCreated();
|
||||
else
|
||||
@@ -304,7 +345,8 @@ public class QuestionsController : ControllerBase
|
||||
question,
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating
|
||||
Rating = rating,
|
||||
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class AccessCatalog
|
||||
{
|
||||
public const int FreeQuestionLimit = 20;
|
||||
public const int CreditEarningLimit = 20;
|
||||
public const int DayPassCreditCost = 100;
|
||||
|
||||
public static readonly IReadOnlyDictionary<string, AccessProduct> Products =
|
||||
new Dictionary<string, AccessProduct>(StringComparer.Ordinal)
|
||||
{
|
||||
["access_day_1"] = new("access_day_1", "One-day QBank token", 100, "usd", AccessGrant.DayToken),
|
||||
["access_week_1"] = new("access_week_1", "Seven-day QBank token", 500, "usd", AccessGrant.WeekToken),
|
||||
["access_month_1"] = new("access_month_1", "Thirty-day QBank token", 2000, "usd", AccessGrant.MonthToken),
|
||||
["exam_token_1"] = new("exam_token_1", "Exam simulation token", 199, "usd", AccessGrant.ExamToken),
|
||||
["qbank_reset_1"] = new("qbank_reset_1", "QBank reset token", 499, "usd", AccessGrant.ResetToken)
|
||||
};
|
||||
}
|
||||
|
||||
public static class AccessGrant
|
||||
{
|
||||
public const string DayToken = "day_token";
|
||||
public const string WeekToken = "week_token";
|
||||
public const string MonthToken = "month_token";
|
||||
public const string ExamToken = "exam_token";
|
||||
public const string ResetToken = "reset_token";
|
||||
}
|
||||
|
||||
public sealed record AccessProduct(string Sku, string Name, long AmountCents, string Currency, string Grant);
|
||||
|
||||
public sealed class AccessEvent
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string SubjectId { get; set; } = "";
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserAccessAccount
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public int Credits { get; set; }
|
||||
public int DayTokens { get; set; }
|
||||
public int WeekTokens { get; set; }
|
||||
public int MonthTokens { get; set; }
|
||||
public int ExamTokens { get; set; }
|
||||
public int ResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public DateTime? UnlimitedUntilUtc { get; set; }
|
||||
public List<AccessEvent> RecentUsage { get; set; } = [];
|
||||
public List<AccessEvent> RecentCredits { get; set; } = [];
|
||||
public List<string> AppliedOperationIds { get; set; } = [];
|
||||
public int Revision { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class AccessStatusResponse
|
||||
{
|
||||
public int FreeQuestionLimit { get; set; } = AccessCatalog.FreeQuestionLimit;
|
||||
public int QuestionsUsed { get; set; }
|
||||
public int QuestionsRemaining { get; set; }
|
||||
public DateTime? NextQuestionAvailableUtc { get; set; }
|
||||
public int Credits { get; set; }
|
||||
public int DayPassCreditCost { get; set; } = AccessCatalog.DayPassCreditCost;
|
||||
public int DayTokens { get; set; }
|
||||
public int WeekTokens { get; set; }
|
||||
public int MonthTokens { get; set; }
|
||||
public int ExamTokens { get; set; }
|
||||
public int ResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public bool Unlimited { get; set; }
|
||||
public DateTime? UnlimitedUntilUtc { get; set; }
|
||||
public List<AccessProduct> Products { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ActivateAccessTokenRequest
|
||||
{
|
||||
public string Token { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class RedeemCreditsRequest
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class ResetQBankRequest
|
||||
{
|
||||
public string Confirmation { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class CreateAccessCheckoutRequest
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class AccessCheckoutResponse
|
||||
{
|
||||
public string PurchaseId { get; set; } = "";
|
||||
public string CheckoutUrl { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
}
|
||||
|
||||
public static class AccessPurchaseStatus
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Paid = "paid";
|
||||
public const string Failed = "failed";
|
||||
public const string Expired = "expired";
|
||||
public const string Refunded = "refunded";
|
||||
}
|
||||
|
||||
public sealed class AccessPurchase
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "usd";
|
||||
public string Status { get; set; } = AccessPurchaseStatus.Pending;
|
||||
public string Provider { get; set; } = "stripe";
|
||||
public string ProviderSessionId { get; set; } = "";
|
||||
public string ProviderPaymentId { get; set; } = "";
|
||||
public List<string> ProviderEventIds { get; set; } = [];
|
||||
public bool GrantApplied { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class AccessPurchaseStatusResponse
|
||||
{
|
||||
public string PurchaseId { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
}
|
||||
@@ -11,6 +11,8 @@ public class QuestionAttempt
|
||||
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public int BankCycle { get; set; }
|
||||
public string CompletionId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public bool TracksPresentation { get; set; }
|
||||
|
||||
@@ -45,6 +45,7 @@ public class FlagQuestionRequest
|
||||
|
||||
public class FirstQuestionAttemptRequest
|
||||
{
|
||||
public string CompletionId { get; set; } = "";
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
|
||||
@@ -141,6 +141,7 @@ public class FirstQuestionAttemptResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public AccessStatusResponse? Access { get; set; }
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
@@ -150,6 +151,7 @@ public class SkipQuestionResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public AccessStatusResponse? Access { get; set; }
|
||||
}
|
||||
|
||||
public static class RatingCategory
|
||||
|
||||
@@ -66,6 +66,10 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<MongoDatabaseProvider>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<AccessStore>();
|
||||
builder.Services.AddSingleton<AccessPurchaseStore>();
|
||||
builder.Services.AddSingleton<IStripeAccessGateway, StripeAccessGateway>();
|
||||
builder.Services.AddSingleton<QuestionStore>();
|
||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||
builder.Services.AddSingleton<AnswerStatisticsStore>();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AccessPurchaseStore
|
||||
{
|
||||
private readonly IMongoCollection<AccessPurchase> _purchases;
|
||||
|
||||
public AccessPurchaseStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_purchases = mongo.Database.GetCollection<AccessPurchase>("AccessPurchases");
|
||||
_purchases.Indexes.CreateMany([
|
||||
new CreateIndexModel<AccessPurchase>(
|
||||
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)),
|
||||
new CreateIndexModel<AccessPurchase>(
|
||||
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.ProviderSessionId),
|
||||
new CreateIndexOptions<AccessPurchase>
|
||||
{
|
||||
Unique = true,
|
||||
PartialFilterExpression = Builders<AccessPurchase>.Filter.Ne(value => value.ProviderSessionId, "")
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
public async Task<AccessPurchase> CreateAsync(string userId, AccessProduct product)
|
||||
{
|
||||
var purchase = new AccessPurchase
|
||||
{
|
||||
UserId = userId,
|
||||
Sku = product.Sku,
|
||||
AmountCents = product.AmountCents,
|
||||
Currency = product.Currency
|
||||
};
|
||||
await _purchases.InsertOneAsync(purchase);
|
||||
return purchase;
|
||||
}
|
||||
|
||||
public async Task<AccessPurchase?> GetOwnedAsync(string id, string userId) =>
|
||||
await _purchases.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<AccessPurchase?> GetAsync(string id) =>
|
||||
await _purchases.Find(value => value.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public Task AttachSessionAsync(string id, string sessionId) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.ProviderSessionId, sessionId)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public Task MarkCheckoutFailedAsync(string id) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.Status, AccessPurchaseStatus.Failed)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public async Task<AccessPurchase?> ApplyProviderStatusAsync(
|
||||
string id, string sessionId, long amountCents, string currency,
|
||||
string status, string paymentId, string eventId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(sessionId)) return null;
|
||||
var filters = Builders<AccessPurchase>.Filter;
|
||||
var filter = filters.Eq(value => value.Id, id) &
|
||||
filters.Eq(value => value.AmountCents, amountCents) &
|
||||
filters.Eq(value => value.Currency, currency.ToLowerInvariant()) &
|
||||
(filters.Eq(value => value.ProviderSessionId, sessionId) |
|
||||
filters.Eq(value => value.ProviderSessionId, "")) &
|
||||
filters.Not(filters.AnyEq(value => value.ProviderEventIds, eventId));
|
||||
var update = Builders<AccessPurchase>.Update
|
||||
.Set(value => value.Status, status)
|
||||
.Set(value => value.ProviderSessionId, sessionId)
|
||||
.Set(value => value.ProviderPaymentId, paymentId)
|
||||
.AddToSet(value => value.ProviderEventIds, eventId)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow);
|
||||
return await _purchases.FindOneAndUpdateAsync(filter, update,
|
||||
new FindOneAndUpdateOptions<AccessPurchase> { ReturnDocument = ReturnDocument.After });
|
||||
}
|
||||
|
||||
public Task MarkGrantAppliedAsync(string id) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Paid,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.GrantApplied, true)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public static AccessPurchaseStatusResponse ToStatus(AccessPurchase purchase) => new()
|
||||
{
|
||||
PurchaseId = purchase.Id ?? "",
|
||||
Sku = purchase.Sku,
|
||||
AmountCents = purchase.AmountCents,
|
||||
Currency = purchase.Currency,
|
||||
Status = purchase.Status
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AccessStore
|
||||
{
|
||||
private readonly IMongoCollection<UserAccessAccount> _accounts;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public AccessStore(MongoDatabaseProvider mongo, TimeProvider time)
|
||||
{
|
||||
_accounts = mongo.Database.GetCollection<UserAccessAccount>("UserAccessAccounts");
|
||||
_time = time;
|
||||
_accounts.Indexes.CreateOne(new CreateIndexModel<UserAccessAccount>(
|
||||
Builders<UserAccessAccount>.IndexKeys.Ascending(value => value.UserId),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_access_user" }));
|
||||
}
|
||||
|
||||
public async Task<AccessStatusResponse> GetStatusAsync(string userId, bool bypass = false)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
return ToStatus(account, now, bypass);
|
||||
}
|
||||
|
||||
public async Task<int> GetBankCycleAsync(string userId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
|
||||
}
|
||||
|
||||
public async Task<bool> CanLoadQuestionAsync(string userId, bool bypass)
|
||||
{
|
||||
if (bypass) return true;
|
||||
var status = await GetStatusAsync(userId);
|
||||
return status.Unlimited || status.QuestionsRemaining > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> ClaimQuestionAsync(string userId, string completionId, string questionExternalId, bool bypass)
|
||||
{
|
||||
if (bypass) return true;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var exactClaim = filters.ElemMatch(value => value.RecentUsage,
|
||||
item => item.Id == completionId && item.SubjectId == questionExternalId);
|
||||
if (await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync())
|
||||
return true;
|
||||
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
|
||||
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
|
||||
{
|
||||
new MongoDB.Bson.BsonDocument("$size", "$RecentUsage"),
|
||||
AccessCatalog.FreeQuestionLimit
|
||||
})));
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.ElemMatch(value => value.RecentUsage, item => item.Id == completionId)) &
|
||||
(filters.Gt(value => value.UnlimitedUntilUtc, now) |
|
||||
capacity);
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.AddToSet(value => value.RecentUsage, new AccessEvent
|
||||
{
|
||||
Id = completionId,
|
||||
SubjectId = questionExternalId,
|
||||
CreatedUtc = now
|
||||
})
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
if ((await _accounts.UpdateOneAsync(filter, update)).MatchedCount == 1)
|
||||
return true;
|
||||
return await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> TryAwardCreditAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
|
||||
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
|
||||
{
|
||||
new MongoDB.Bson.BsonDocument("$size", "$RecentCredits"),
|
||||
AccessCatalog.CreditEarningLimit
|
||||
})));
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)) &
|
||||
capacity;
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.Credits, 1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Push(value => value.RecentCredits, new AccessEvent { Id = operationId, CreatedUtc = now })
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public Task<AccessStatusResponse?> RedeemCreditsAsync(string userId, string operationId) =>
|
||||
SpendAndExtendAsync(userId, operationId, AccessCatalog.DayPassCreditCost, null, TimeSpan.FromHours(24));
|
||||
|
||||
public Task<AccessStatusResponse?> ActivateTokenAsync(string userId, string operationId, string token) =>
|
||||
token.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"day" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.DayTokens), TimeSpan.FromHours(24)),
|
||||
"week" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.WeekTokens), TimeSpan.FromHours(168)),
|
||||
"month" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.MonthTokens), TimeSpan.FromHours(720)),
|
||||
_ => Task.FromResult<AccessStatusResponse?>(null)
|
||||
};
|
||||
|
||||
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Gt(value => value.ExamTokens, 0) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ExamTokens, -1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public async Task RefundExamTokenAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var filter = Builders<UserAccessAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserAccessAccount>.Filter.AnyEq(value => value.AppliedOperationIds, operationId);
|
||||
await _accounts.UpdateOneAsync(filter, Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ExamTokens, 1)
|
||||
.Pull(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now));
|
||||
}
|
||||
|
||||
public async Task<int?> ApplyResetAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Gt(value => value.ResetTokens, 0) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var updated = await _accounts.FindOneAndUpdateAsync(
|
||||
filter,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ResetTokens, -1)
|
||||
.Inc(value => value.BankCycle, 1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1),
|
||||
new FindOneAndUpdateOptions<UserAccessAccount> { ReturnDocument = ReturnDocument.After });
|
||||
return updated?.BankCycle;
|
||||
}
|
||||
|
||||
public async Task<bool> ApplyProductGrantAsync(string userId, AccessProduct product, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
update = product.Grant switch
|
||||
{
|
||||
AccessGrant.DayToken => update.Inc(value => value.DayTokens, 1),
|
||||
AccessGrant.WeekToken => update.Inc(value => value.WeekTokens, 1),
|
||||
AccessGrant.MonthToken => update.Inc(value => value.MonthTokens, 1),
|
||||
AccessGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
|
||||
AccessGrant.ResetToken => update.Inc(value => value.ResetTokens, 1),
|
||||
_ => update
|
||||
};
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
private async Task<AccessStatusResponse?> SpendAndExtendAsync(
|
||||
string userId, string operationId, int credits, string? tokenProperty, TimeSpan duration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(operationId)) return null;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
if (credits > 0) filter &= filters.Gte(value => value.Credits, credits);
|
||||
filter &= tokenProperty switch
|
||||
{
|
||||
nameof(UserAccessAccount.DayTokens) => filters.Gt(value => value.DayTokens, 0),
|
||||
nameof(UserAccessAccount.WeekTokens) => filters.Gt(value => value.WeekTokens, 0),
|
||||
nameof(UserAccessAccount.MonthTokens) => filters.Gt(value => value.MonthTokens, 0),
|
||||
_ => FilterDefinition<UserAccessAccount>.Empty
|
||||
};
|
||||
var account = await _accounts.Find(filter).FirstOrDefaultAsync();
|
||||
if (account is null) return null;
|
||||
var start = account.UnlimitedUntilUtc is { } expiry && expiry > now ? expiry : now;
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Set(value => value.UnlimitedUntilUtc, start.Add(duration))
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
if (credits > 0) update = update.Inc(value => value.Credits, -credits);
|
||||
update = tokenProperty switch
|
||||
{
|
||||
nameof(UserAccessAccount.DayTokens) => update.Inc(value => value.DayTokens, -1),
|
||||
nameof(UserAccessAccount.WeekTokens) => update.Inc(value => value.WeekTokens, -1),
|
||||
nameof(UserAccessAccount.MonthTokens) => update.Inc(value => value.MonthTokens, -1),
|
||||
_ => update
|
||||
};
|
||||
var applied = await _accounts.UpdateOneAsync(
|
||||
filter & filters.Eq(value => value.Revision, account.Revision), update);
|
||||
return applied.ModifiedCount == 1 ? await GetStatusAsync(userId) : null;
|
||||
}
|
||||
|
||||
private async Task EnsureAndCleanAsync(string userId, DateTime now)
|
||||
{
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.SetOnInsert(value => value.UserId, userId)
|
||||
.SetOnInsert(value => value.UpdatedUtc, now),
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
var cutoff = now.AddHours(-24);
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.PullFilter(value => value.RecentUsage, item => item.CreatedUtc <= cutoff)
|
||||
.PullFilter(value => value.RecentCredits, item => item.CreatedUtc <= cutoff));
|
||||
}
|
||||
|
||||
private static AccessStatusResponse ToStatus(UserAccessAccount account, DateTime now, bool bypass)
|
||||
{
|
||||
var unlimited = bypass || account.UnlimitedUntilUtc > now;
|
||||
var used = account.RecentUsage.Count;
|
||||
return new AccessStatusResponse
|
||||
{
|
||||
QuestionsUsed = used,
|
||||
QuestionsRemaining = unlimited ? AccessCatalog.FreeQuestionLimit : Math.Max(0, AccessCatalog.FreeQuestionLimit - used),
|
||||
NextQuestionAvailableUtc = !unlimited && used >= AccessCatalog.FreeQuestionLimit
|
||||
? account.RecentUsage.MinBy(value => value.CreatedUtc)?.CreatedUtc.AddHours(24)
|
||||
: null,
|
||||
Credits = account.Credits,
|
||||
DayTokens = account.DayTokens,
|
||||
WeekTokens = account.WeekTokens,
|
||||
MonthTokens = account.MonthTokens,
|
||||
ExamTokens = account.ExamTokens,
|
||||
ResetTokens = account.ResetTokens,
|
||||
BankCycle = account.BankCycle,
|
||||
Unlimited = unlimited,
|
||||
UnlimitedUntilUtc = account.UnlimitedUntilUtc,
|
||||
Products = AccessCatalog.Products.Values.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public sealed class DashboardStore
|
||||
private readonly FlashcardScheduleStore _flashcardSchedules;
|
||||
private readonly RatingStore _ratingStore;
|
||||
private readonly CcsRatingStore _ccsRatingStore;
|
||||
private readonly AccessStore _accessStore;
|
||||
private readonly IMongoCollection<UserRating> _ratings;
|
||||
|
||||
public static readonly string[] Step3Systems =
|
||||
@@ -48,7 +49,8 @@ public sealed class DashboardStore
|
||||
FlashcardStudyService flashcards,
|
||||
FlashcardScheduleStore flashcardSchedules,
|
||||
RatingStore ratingStore,
|
||||
CcsRatingStore ccsRatingStore)
|
||||
CcsRatingStore ccsRatingStore,
|
||||
AccessStore accessStore)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||
@@ -65,6 +67,7 @@ public sealed class DashboardStore
|
||||
_flashcardSchedules = flashcardSchedules;
|
||||
_ratingStore = ratingStore;
|
||||
_ccsRatingStore = ccsRatingStore;
|
||||
_accessStore = accessStore;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
@@ -72,7 +75,9 @@ public sealed class DashboardStore
|
||||
var today = nowUtc.Date;
|
||||
var trendStart = today.AddDays(-29);
|
||||
var weekStart = today.AddDays(-6);
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
var bankCycleTask = _accessStore.GetBankCycleAsync(userId);
|
||||
var bankCycle = await bankCycleTask;
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId && value.BankCycle == bankCycle).ToListAsync();
|
||||
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
|
||||
@@ -15,12 +15,20 @@ public class QuestionAttemptStore
|
||||
{
|
||||
_col = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
|
||||
try
|
||||
{
|
||||
_col.Indexes.DropOne("QuestionExternalId_1_UserId_1");
|
||||
}
|
||||
catch (MongoCommandException exception) when (exception.Code is 26 or 27)
|
||||
{
|
||||
}
|
||||
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(a => a.QuestionExternalId)
|
||||
.Ascending(a => a.UserId);
|
||||
.Ascending(a => a.UserId)
|
||||
.Ascending(a => a.BankCycle);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||
firstAttemptIndex,
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
|
||||
|
||||
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
@@ -38,12 +46,16 @@ public class QuestionAttemptStore
|
||||
bool isCorrect,
|
||||
bool isSkip = false,
|
||||
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||
int durationSeconds = 0)
|
||||
int durationSeconds = 0,
|
||||
int bankCycle = 0,
|
||||
string completionId = "")
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
QuestionExternalId = questionExternalId,
|
||||
UserId = userId,
|
||||
BankCycle = bankCycle,
|
||||
CompletionId = completionId,
|
||||
ChoiceId = choiceId,
|
||||
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
|
||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||
@@ -63,7 +75,7 @@ public class QuestionAttemptStore
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
var existing = await _col
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId && a.BankCycle == bankCycle)
|
||||
.FirstAsync();
|
||||
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
||||
}
|
||||
@@ -75,10 +87,13 @@ public class QuestionAttemptStore
|
||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) =>
|
||||
_col.Distinct(
|
||||
attempt => attempt.QuestionExternalId,
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId) &
|
||||
(bankCycle is null
|
||||
? FilterDefinition<QuestionAttempt>.Empty
|
||||
: Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value)))
|
||||
.ToListAsync();
|
||||
|
||||
public IMongoCollection<QuestionAttempt> Collection => _col;
|
||||
|
||||
@@ -41,6 +41,18 @@ public class RatingStore
|
||||
return await EnsureHistoryBaselineAsync(rating, nowUtc);
|
||||
}
|
||||
|
||||
public Task ResetUserAsync(string userId, DateTime nowUtc) =>
|
||||
_users.UpdateOneAsync(
|
||||
rating => rating.UserId == userId,
|
||||
Builders<UserRating>.Update
|
||||
.Set(rating => rating.OverallElo, BaseElo)
|
||||
.Set(rating => rating.RatedQuestionCount, 0)
|
||||
.Set(rating => rating.CategoryElos, DefaultCategoryElos())
|
||||
.Set(rating => rating.HistoryBaselineElo, null)
|
||||
.Set(rating => rating.HistoryStartedUtc, null)
|
||||
.Set(rating => rating.UpdatedUtc, nowUtc),
|
||||
new UpdateOptions { IsUpsert = false });
|
||||
|
||||
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
|
||||
{
|
||||
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using QuestionsApi.Models;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public interface IStripeAccessGateway
|
||||
{
|
||||
Task<StripeCheckoutResult> CreateCheckoutAsync(AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class StripeAccessGateway(IConfiguration configuration) : IStripeAccessGateway
|
||||
{
|
||||
public async Task<StripeCheckoutResult> CreateCheckoutAsync(
|
||||
AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken)
|
||||
{
|
||||
var secretKey = configuration["Stripe:SecretKey"];
|
||||
if (string.IsNullOrWhiteSpace(secretKey)) throw new InvalidOperationException("Stripe secret key is not configured.");
|
||||
var successUrl = configuration["Stripe:AccessSuccessUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}";
|
||||
var cancelUrl = configuration["Stripe:AccessCancelUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-canceled.html";
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["access_purchase_id"] = purchase.Id ?? "",
|
||||
["access_sku"] = product.Sku,
|
||||
["user_id"] = purchase.UserId
|
||||
};
|
||||
var service = new SessionService(new StripeClient(secretKey));
|
||||
var session = await service.CreateAsync(new SessionCreateOptions
|
||||
{
|
||||
Mode = "payment",
|
||||
SuccessUrl = successUrl,
|
||||
CancelUrl = cancelUrl,
|
||||
ClientReferenceId = purchase.Id,
|
||||
CustomerCreation = "always",
|
||||
BillingAddressCollection = "auto",
|
||||
AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true },
|
||||
Metadata = metadata,
|
||||
PaymentIntentData = new SessionPaymentIntentDataOptions { Metadata = metadata },
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions
|
||||
{
|
||||
Quantity = 1,
|
||||
PriceData = new SessionLineItemPriceDataOptions
|
||||
{
|
||||
Currency = product.Currency,
|
||||
UnitAmount = product.AmountCents,
|
||||
ProductData = new SessionLineItemPriceDataProductDataOptions
|
||||
{
|
||||
Name = product.Name,
|
||||
Description = "A manually activated RanaZe access entitlement."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}, null, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(session.Id) || string.IsNullOrWhiteSpace(session.Url))
|
||||
throw new InvalidOperationException("Stripe did not return a checkout URL.");
|
||||
return new StripeCheckoutResult(session.Id, session.Url);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public sealed class UserProfileStore
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserAccessAccounts", "AccessPurchases"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
||||
"Stripe": {
|
||||
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html",
|
||||
"AccessSuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"AccessCancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
},
|
||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||
"PasswordReset": {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
||||
"Stripe": {
|
||||
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html",
|
||||
"AccessSuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"AccessCancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
},
|
||||
"Steam": {
|
||||
"Realm": "https://s3q.ranaze.com",
|
||||
|
||||
Reference in New Issue
Block a user