Add freemium access economy and token store

This commit is contained in:
hz
2026-07-27 22:20:21 -05:00
parent 1d5d03a4f6
commit 4bbac27b84
40 changed files with 1807 additions and 37 deletions
@@ -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"))
});
}