Add server-authoritative RZ Coin rewards
This commit is contained in:
@@ -14,6 +14,19 @@ public sealed class CreditsController(CreditStore credits, ExamSimulationStore s
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
|
||||
|
||||
[HttpGet("ledger")]
|
||||
public async Task<IActionResult> Ledger([FromQuery] int limit = 20, [FromQuery] string? before = null)
|
||||
{
|
||||
DateTime? cursor = null;
|
||||
if (!string.IsNullOrWhiteSpace(before))
|
||||
{
|
||||
if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
|
||||
return BadRequest(new { code = "invalid_cursor", message = "The ledger cursor is invalid." });
|
||||
cursor = parsed;
|
||||
}
|
||||
return Ok(await credits.GetLedgerAsync(UserId(), limit, cursor));
|
||||
}
|
||||
|
||||
[HttpPost("purchases")]
|
||||
public async Task<IActionResult> Purchase([FromBody] CreditPurchaseRequest request)
|
||||
{
|
||||
|
||||
@@ -9,7 +9,10 @@ namespace QuestionsApi.Controllers;
|
||||
[ApiController]
|
||||
[Route("api/exam-simulations")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase
|
||||
public class ExamSimulationsController(
|
||||
ExamSimulationStore simulations,
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards) : ControllerBase
|
||||
{
|
||||
[HttpGet("readiness")]
|
||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||
@@ -132,7 +135,23 @@ public class ExamSimulationsController(ExamSimulationStore simulations, CreditSt
|
||||
{
|
||||
var simulation = await simulations.GetOwnedAsync(id, UserId());
|
||||
if (simulation is null) return NotFound();
|
||||
try { return Ok(await simulations.ToResponseAsync(await simulations.SubmitBlockAsync(simulation))); }
|
||||
try
|
||||
{
|
||||
var userId = UserId();
|
||||
await masteryAwards.PrepareAsync(userId);
|
||||
simulation = await simulations.SubmitBlockAsync(simulation);
|
||||
var response = await simulations.ToResponseAsync(simulation);
|
||||
if (simulation.State == ExamSimulationStates.Completed && !simulation.IsSample)
|
||||
{
|
||||
var result = await credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"simulation:{simulation.Id}", 10, CreditReasonCodes.ExamSimulationComplete,
|
||||
"Completed a Step 3 exam simulation", "exam_simulation", simulation.Id ?? "", false));
|
||||
if (result.Award is not null) response.CreditAwards.Add(result.Award);
|
||||
response.CreditAwards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
|
||||
response.Credits = await credits.GetStatusAsync(userId);
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ public class QuestionsController : ControllerBase
|
||||
private readonly QuestionBookmarkStore _bookmarks;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly MasteryAwardService _masteryAwards;
|
||||
|
||||
public QuestionsController(
|
||||
QuestionStore questions,
|
||||
@@ -30,7 +31,8 @@ public class QuestionsController : ControllerBase
|
||||
ServiceMetrics metrics,
|
||||
QuestionBookmarkStore bookmarks,
|
||||
ReviewQueueStore reviewQueue,
|
||||
CreditStore credits)
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -41,6 +43,7 @@ public class QuestionsController : ControllerBase
|
||||
_bookmarks = bookmarks;
|
||||
_reviewQueue = reviewQueue;
|
||||
_credits = credits;
|
||||
_masteryAwards = masteryAwards;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -152,6 +155,8 @@ public class QuestionsController : ControllerBase
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
|
||||
await _masteryAwards.PrepareAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
userId,
|
||||
@@ -178,8 +183,18 @@ public class QuestionsController : ControllerBase
|
||||
_metrics.AttemptDuplicate();
|
||||
}
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit)
|
||||
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
|
||||
var creditAwards = new List<CreditAward>();
|
||||
if (record.Inserted && record.Attempt.IsCorrect)
|
||||
{
|
||||
var amount = CreditEconomy.FirstCorrectAmount(question.Difficulty);
|
||||
var difficult = question.Difficulty >= 4;
|
||||
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"answer:{record.Attempt.Id}", amount,
|
||||
difficult ? CreditReasonCodes.DifficultCorrect : CreditReasonCodes.FirstCorrect,
|
||||
difficult ? $"Difficulty {question.Difficulty} first correct answer" : "First correct answer",
|
||||
"question_attempt", record.Attempt.Id ?? ""));
|
||||
if (award.Award is not null) creditAwards.Add(award.Award);
|
||||
}
|
||||
|
||||
var ratingOwner = Guid.NewGuid().ToString("N");
|
||||
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
|
||||
@@ -205,6 +220,7 @@ public class QuestionsController : ControllerBase
|
||||
throw;
|
||||
}
|
||||
|
||||
creditAwards.AddRange(await _masteryAwards.AwardPromotionsAsync(userId));
|
||||
return Ok(new FirstQuestionAttemptResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
@@ -213,7 +229,8 @@ public class QuestionsController : ControllerBase
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating,
|
||||
Credits = await _credits.GetStatusAsync(userId)
|
||||
Credits = await _credits.GetStatusAsync(userId),
|
||||
CreditAwards = creditAwards
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ public sealed class ReviewQueueController(
|
||||
QuestionStore questions,
|
||||
AnswerStatisticsStore statistics,
|
||||
RatingStore ratings,
|
||||
QuestionBookmarkStore bookmarks) : ControllerBase
|
||||
QuestionBookmarkStore bookmarks,
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards) : ControllerBase
|
||||
{
|
||||
[HttpPost("next")]
|
||||
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
|
||||
@@ -71,6 +73,7 @@ public sealed class ReviewQueueController(
|
||||
return BadRequest("presentedChoiceIds must include valid selected and correct choices");
|
||||
}
|
||||
|
||||
await masteryAwards.PrepareAsync(userId);
|
||||
var result = await queue.RecordReviewAsync(
|
||||
userId,
|
||||
req.ClientAttemptId,
|
||||
@@ -81,12 +84,27 @@ public sealed class ReviewQueueController(
|
||||
choiceId == question.CorrectChoiceId,
|
||||
req.DurationSeconds);
|
||||
if (result is null) return Conflict("review cycle is no longer active");
|
||||
var awards = new List<CreditAward>();
|
||||
if (result.Attempt.IsCorrect)
|
||||
{
|
||||
var delayed = result.Item is not null
|
||||
&& result.Attempt.CreatedUtc - result.Item.CreatedUtc >= TimeSpan.FromHours(24);
|
||||
var award = await credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"review:{result.Attempt.Id}", CreditEconomy.ReviewAmount(delayed),
|
||||
delayed ? CreditReasonCodes.DelayedRecovery : CreditReasonCodes.ScheduledReview,
|
||||
delayed ? "Delayed recovery of a missed question" : "Correct scheduled review",
|
||||
"question_review", result.Attempt.Id));
|
||||
if (award.Award is not null) awards.Add(award.Award);
|
||||
}
|
||||
awards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
|
||||
return Ok(new ReviewQueueAttemptResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
IsCorrect = result.Attempt.IsCorrect,
|
||||
QueueState = result.Item?.State ?? ReviewQueueStates.Pending,
|
||||
PendingCount = checked((int)await queue.CountPendingAsync(userId))
|
||||
PendingCount = checked((int)await queue.CountPendingAsync(userId)),
|
||||
Credits = await credits.GetStatusAsync(userId),
|
||||
CreditAwards = awards
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace QuestionsApi.Models;
|
||||
|
||||
public static class CreditEconomy
|
||||
{
|
||||
public const int RollingEarningLimit = 20;
|
||||
public const int RollingEarningLimit = 30;
|
||||
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
|
||||
public const string ExamTokenSku = "exam_token_1";
|
||||
public const string QBankResetTokenSku = "qbank_reset_1";
|
||||
@@ -34,6 +34,9 @@ public static class CreditEconomy
|
||||
|
||||
public static RzStoreProduct? FindProduct(string sku) =>
|
||||
Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal));
|
||||
|
||||
public static int FirstCorrectAmount(int difficulty) => difficulty >= 5 ? 3 : difficulty >= 4 ? 2 : 1;
|
||||
public static int ReviewAmount(bool delayedRecovery) => delayedRecovery ? 3 : 1;
|
||||
}
|
||||
|
||||
public static class SlotMachineEconomy
|
||||
@@ -92,6 +95,65 @@ public sealed class RzStoreProduct
|
||||
public sealed class CreditEvent
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
public int CoinAmount { get; set; } = 1;
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public static class CreditReasonCodes
|
||||
{
|
||||
public const string FirstCorrect = "qbank_first_correct";
|
||||
public const string DifficultCorrect = "qbank_difficult_correct";
|
||||
public const string ScheduledReview = "scheduled_review_correct";
|
||||
public const string DelayedRecovery = "delayed_recovery";
|
||||
public const string MasteryPracticing = "mastery_practicing";
|
||||
public const string MasteryStrong = "mastery_strong";
|
||||
public const string MasteryMastered = "mastery_mastered";
|
||||
public const string ExamSimulationComplete = "exam_simulation_complete";
|
||||
public const string Purchase = "purchase";
|
||||
}
|
||||
|
||||
public sealed class CreditLedgerEntry
|
||||
{
|
||||
[BsonId]
|
||||
public string Id { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
public int RequestedAmount { get; set; }
|
||||
public int Amount { get; set; }
|
||||
public string ReasonCode { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string SourceType { get; set; } = "";
|
||||
public string SourceId { get; set; } = "";
|
||||
public bool SubjectToRollingCap { get; set; }
|
||||
public string State { get; set; } = "pending";
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime? AppliedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreditAward
|
||||
{
|
||||
public int Amount { get; set; }
|
||||
public string ReasonCode { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class CreditAwardResult
|
||||
{
|
||||
public CreditAward? Award { get; set; }
|
||||
public CreditStatusResponse Status { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class CreditLedgerPage
|
||||
{
|
||||
public List<CreditLedgerEntryResponse> Items { get; set; } = [];
|
||||
public DateTime? NextBefore { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreditLedgerEntryResponse
|
||||
{
|
||||
public int Amount { get; set; }
|
||||
public string ReasonCode { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
@@ -122,6 +184,7 @@ public sealed class UserCreditAccount
|
||||
public int QBankResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public bool InitialExamGrantApplied { get; set; }
|
||||
public bool MasteryAwardBaselineApplied { get; set; }
|
||||
public List<CreditEvent> RecentEarnings { get; set; } = [];
|
||||
public List<CreditPurchaseEvent> Purchases { get; set; } = [];
|
||||
public List<SpinEvent> Spins { get; set; } = [];
|
||||
@@ -142,6 +205,15 @@ public sealed class CreditStatusResponse
|
||||
public IReadOnlyList<RzStoreProduct> Products { get; set; } = CreditEconomy.Products;
|
||||
}
|
||||
|
||||
public sealed record CreditAwardRequest(
|
||||
string OperationId,
|
||||
int Amount,
|
||||
string ReasonCode,
|
||||
string Description,
|
||||
string SourceType,
|
||||
string SourceId,
|
||||
bool SubjectToRollingCap = true);
|
||||
|
||||
public sealed class CreditPurchaseRequest
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
|
||||
@@ -117,6 +117,8 @@ public class ExamSimulationStateResponse
|
||||
public Dictionary<string, int> BlueprintTargets { get; set; } = [];
|
||||
public Dictionary<string, int> BlueprintActual { get; set; } = [];
|
||||
public List<QuestionResponse> ReviewQuestions { get; set; } = [];
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ExamSimulationReadinessResponse
|
||||
|
||||
@@ -146,6 +146,7 @@ public class FirstQuestionAttemptResponse
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
|
||||
@@ -81,6 +81,8 @@ public sealed class ReviewQueueAttemptResponse
|
||||
public bool IsCorrect { get; set; }
|
||||
public string QueueState { get; set; } = ReviewQueueStates.Pending;
|
||||
public int PendingCount { get; set; }
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueRecordResult
|
||||
|
||||
@@ -90,6 +90,8 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
builder.Services.AddSingleton<MongoDatabaseProvider>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<CreditStore>();
|
||||
builder.Services.AddSingleton<MasteryAwardService>();
|
||||
builder.Services.AddHostedService<CreditLedgerReconciliationService>();
|
||||
builder.Services.AddSingleton<QuestionStore>();
|
||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||
builder.Services.AddSingleton<AnswerStatisticsStore>();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class CreditLedgerReconciliationService(
|
||||
CreditStore credits,
|
||||
ILogger<CreditLedgerReconciliationService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await credits.ReconcilePendingAsync(stoppingToken); }
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; }
|
||||
catch (Exception exception) { logger.LogError(exception, "Credit ledger reconciliation failed."); }
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,43 +7,167 @@ namespace QuestionsApi.Services;
|
||||
public sealed class CreditStore
|
||||
{
|
||||
private readonly IMongoCollection<UserCreditAccount> _accounts;
|
||||
private readonly IMongoCollection<CreditLedgerEntry> _ledger;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
|
||||
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
|
||||
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock, ServiceMetrics metrics)
|
||||
{
|
||||
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
|
||||
_ledger = mongo.Database.GetCollection<CreditLedgerEntry>("CreditLedgerEntries");
|
||||
_clock = clock;
|
||||
_metrics = metrics;
|
||||
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
|
||||
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
|
||||
_ledger.Indexes.CreateOne(new CreateIndexModel<CreditLedgerEntry>(
|
||||
Builders<CreditLedgerEntry>.IndexKeys.Ascending(value => value.UserId)
|
||||
.Descending(value => value.CreatedUtc).Descending(value => value.Id),
|
||||
new CreateIndexOptions { Name = "credit_ledger_user_time" }));
|
||||
}
|
||||
|
||||
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
|
||||
public async Task<CreditAwardResult> AwardAsync(string userId, CreditAwardRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attemptId)) return false;
|
||||
if (string.IsNullOrWhiteSpace(request.OperationId) || request.Amount <= 0)
|
||||
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
|
||||
var filters = Builders<UserCreditAccount>.Filter;
|
||||
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
|
||||
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
|
||||
var entryId = LedgerId(userId, request.OperationId);
|
||||
var entry = await _ledger.Find(value => value.Id == entryId).FirstOrDefaultAsync();
|
||||
if (entry is not null && (entry.ReasonCode != request.ReasonCode || entry.SourceId != request.SourceId))
|
||||
throw new InvalidOperationException("The credit operation ID was already used for another event.");
|
||||
if (entry is null)
|
||||
{
|
||||
var alreadyApplied = await _accounts.Find(value => value.UserId == userId
|
||||
&& value.AppliedOperations.Contains(request.OperationId)).AnyAsync();
|
||||
if (alreadyApplied) return new CreditAwardResult { Status = await GetStatusAsync(userId) };
|
||||
entry = new CreditLedgerEntry
|
||||
{
|
||||
new BsonDocument("$size", "$RecentEarnings"),
|
||||
CreditEconomy.RollingEarningLimit
|
||||
})));
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.ElemMatch(value => value.RecentEarnings,
|
||||
earning => earning.OperationId == attemptId)) &
|
||||
belowCap;
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Inc(value => value.Credits, 1)
|
||||
.Push(value => value.RecentEarnings, new CreditEvent
|
||||
{
|
||||
OperationId = attemptId,
|
||||
Id = entryId, UserId = userId, OperationId = request.OperationId,
|
||||
RequestedAmount = request.Amount, Amount = request.SubjectToRollingCap ? 0 : request.Amount,
|
||||
ReasonCode = request.ReasonCode,
|
||||
Description = request.Description, SourceType = request.SourceType,
|
||||
SourceId = request.SourceId, SubjectToRollingCap = request.SubjectToRollingCap,
|
||||
CreatedUtc = now
|
||||
})
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
};
|
||||
try { await _ledger.InsertOneAsync(entry); }
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{ entry = await _ledger.Find(value => value.Id == entryId).FirstAsync(); }
|
||||
}
|
||||
if (entry.State == "applied")
|
||||
{
|
||||
_metrics.CreditDuplicate(request.ReasonCode);
|
||||
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
|
||||
}
|
||||
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
if (account.AppliedOperations.Contains(request.OperationId))
|
||||
{
|
||||
var appliedAmount = AppliedAmount(account, entry);
|
||||
entry.Amount = appliedAmount;
|
||||
await MarkLedgerAppliedAsync(entry, appliedAmount, now);
|
||||
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
|
||||
}
|
||||
var amount = request.Amount;
|
||||
var appliedNow = false;
|
||||
while (true)
|
||||
{
|
||||
var earned = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount));
|
||||
amount = request.SubjectToRollingCap
|
||||
? Math.Min(request.Amount, Math.Max(0, CreditEconomy.RollingEarningLimit - earned))
|
||||
: request.Amount;
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId)
|
||||
& Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [request.OperationId]);
|
||||
if (request.SubjectToRollingCap && amount > 0)
|
||||
filter &= RollingEarnedAtMost(CreditEconomy.RollingEarningLimit - amount);
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.AddToSet(value => value.AppliedOperations, request.OperationId)
|
||||
.Set(value => value.UpdatedUtc, now);
|
||||
if (amount > 0)
|
||||
{
|
||||
update = update.Inc(value => value.Credits, amount);
|
||||
if (request.SubjectToRollingCap)
|
||||
update = update.Push(value => value.RecentEarnings,
|
||||
new CreditEvent { OperationId = request.OperationId, CoinAmount = amount, CreatedUtc = now });
|
||||
}
|
||||
if ((await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1)
|
||||
{
|
||||
appliedNow = true;
|
||||
break;
|
||||
}
|
||||
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
if (account.AppliedOperations.Contains(request.OperationId))
|
||||
{
|
||||
amount = AppliedAmount(account, entry);
|
||||
break;
|
||||
}
|
||||
}
|
||||
entry.Amount = amount;
|
||||
await MarkLedgerAppliedAsync(entry, amount, now);
|
||||
if (appliedNow && amount > 0) _metrics.CreditAward(request.ReasonCode, amount);
|
||||
if (appliedNow && request.SubjectToRollingCap && amount < request.Amount)
|
||||
_metrics.CreditCapped(request.ReasonCode);
|
||||
var status = await GetStatusAsync(userId);
|
||||
return appliedNow ? Result(entry, status) : new CreditAwardResult { Status = status };
|
||||
}
|
||||
|
||||
public async Task InitializeMasteryBaselineAsync(string userId, IEnumerable<string> operationIds)
|
||||
{
|
||||
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||
var update = Builders<UserCreditAccount>.Update
|
||||
.Set(value => value.MasteryAwardBaselineApplied, true);
|
||||
foreach (var operationId in operationIds.Distinct(StringComparer.Ordinal))
|
||||
update = update.AddToSet(value => value.AppliedOperations, operationId);
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId && !value.MasteryAwardBaselineApplied, update);
|
||||
}
|
||||
|
||||
public async Task<bool> IsMasteryBaselineAppliedAsync(string userId)
|
||||
{
|
||||
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).MasteryAwardBaselineApplied;
|
||||
}
|
||||
|
||||
public async Task<HashSet<string>> GetAppliedOperationsAsync(string userId)
|
||||
{
|
||||
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
return account.AppliedOperations.ToHashSet(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public async Task<CreditLedgerPage> GetLedgerAsync(string userId, int limit, DateTime? before)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 50);
|
||||
var filter = Builders<CreditLedgerEntry>.Filter.Eq(value => value.UserId, userId)
|
||||
& Builders<CreditLedgerEntry>.Filter.Eq(value => value.State, "applied")
|
||||
& Builders<CreditLedgerEntry>.Filter.Ne(value => value.Amount, 0);
|
||||
if (before.HasValue) filter &= Builders<CreditLedgerEntry>.Filter.Lt(value => value.CreatedUtc, before.Value);
|
||||
var entries = await _ledger.Find(filter).SortByDescending(value => value.CreatedUtc)
|
||||
.ThenByDescending(value => value.Id).Limit(limit + 1).ToListAsync();
|
||||
var hasMore = entries.Count > limit;
|
||||
if (hasMore) entries.RemoveAt(entries.Count - 1);
|
||||
return new CreditLedgerPage
|
||||
{
|
||||
Items = entries.Select(value => new CreditLedgerEntryResponse
|
||||
{
|
||||
Amount = value.Amount, ReasonCode = value.ReasonCode,
|
||||
Description = value.Description, CreatedUtc = value.CreatedUtc
|
||||
}).ToList(),
|
||||
NextBefore = hasMore && entries.Count > 0 ? entries[^1].CreatedUtc : null
|
||||
};
|
||||
}
|
||||
|
||||
public async Task ReconcilePendingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var pending = await _ledger.Find(value => value.State == "pending")
|
||||
.SortBy(value => value.CreatedUtc).Limit(100).ToListAsync(cancellationToken);
|
||||
foreach (var entry in pending)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await AwardAsync(entry.UserId, new CreditAwardRequest(
|
||||
entry.OperationId, entry.RequestedAmount, entry.ReasonCode, entry.Description,
|
||||
entry.SourceType, entry.SourceId, entry.SubjectToRollingCap));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
|
||||
@@ -68,9 +192,12 @@ public sealed class CreditStore
|
||||
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (prior is not null)
|
||||
return Response(prior.Sku == product.Sku
|
||||
? CreditOperationResult.Succeeded
|
||||
: CreditOperationResult.OperationConflict, existing);
|
||||
{
|
||||
if (prior.Sku != product.Sku)
|
||||
return Response(CreditOperationResult.OperationConflict, existing);
|
||||
await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc);
|
||||
return Response(CreditOperationResult.Succeeded, existing);
|
||||
}
|
||||
|
||||
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
|
||||
@@ -95,14 +222,21 @@ public sealed class CreditStore
|
||||
{
|
||||
ReturnDocument = ReturnDocument.After
|
||||
});
|
||||
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
|
||||
if (account is not null)
|
||||
{
|
||||
await RecordPurchaseLedgerAsync(userId, product, operationId, now);
|
||||
return Response(CreditOperationResult.Succeeded, account);
|
||||
}
|
||||
|
||||
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId);
|
||||
if (prior is not null)
|
||||
return Response(prior.Sku == product.Sku
|
||||
? CreditOperationResult.Succeeded
|
||||
: CreditOperationResult.OperationConflict, account);
|
||||
{
|
||||
if (prior.Sku != product.Sku)
|
||||
return Response(CreditOperationResult.OperationConflict, account);
|
||||
await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc);
|
||||
return Response(CreditOperationResult.Succeeded, account);
|
||||
}
|
||||
return Response(CreditOperationResult.InsufficientCredits, account);
|
||||
}
|
||||
|
||||
@@ -223,6 +357,7 @@ public sealed class CreditStore
|
||||
.SetOnInsert(value => value.QBankResetTokens, 0)
|
||||
.SetOnInsert(value => value.BankCycle, 0)
|
||||
.SetOnInsert(value => value.InitialExamGrantApplied, true)
|
||||
.SetOnInsert(value => value.MasteryAwardBaselineApplied, false)
|
||||
.SetOnInsert(value => value.RecentEarnings, [])
|
||||
.SetOnInsert(value => value.Purchases, [])
|
||||
.SetOnInsert(value => value.Spins, [])
|
||||
@@ -273,9 +408,9 @@ public sealed class CreditStore
|
||||
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
|
||||
{
|
||||
Credits = account.Credits,
|
||||
EarnedInRollingWindow = account.RecentEarnings.Count,
|
||||
EarnedInRollingWindow = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount)),
|
||||
RemainingEarningCapacity = Math.Max(
|
||||
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count),
|
||||
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount))),
|
||||
ExamTokens = account.ExamTokens,
|
||||
QBankResetTokens = account.QBankResetTokens,
|
||||
BankCycle = account.BankCycle
|
||||
@@ -297,4 +432,51 @@ public sealed class CreditStore
|
||||
CoinsWon = spinEvent?.CoinsWon ?? 0,
|
||||
Status = ToStatus(account)
|
||||
};
|
||||
|
||||
private static string LedgerId(string userId, string operationId) => $"{userId}:{operationId}";
|
||||
|
||||
private static int AppliedAmount(UserCreditAccount account, CreditLedgerEntry entry) =>
|
||||
entry.SubjectToRollingCap
|
||||
? account.RecentEarnings.FirstOrDefault(value => value.OperationId == entry.OperationId)?.CoinAmount ?? 0
|
||||
: entry.RequestedAmount;
|
||||
|
||||
private static FilterDefinition<UserCreditAccount> RollingEarnedAtMost(int maximum) =>
|
||||
new BsonDocumentFilterDefinition<UserCreditAccount>(new BsonDocument("$expr",
|
||||
new BsonDocument("$lte", new BsonArray
|
||||
{
|
||||
new BsonDocument("$sum", new BsonDocument("$map", new BsonDocument
|
||||
{
|
||||
{ "input", new BsonDocument("$ifNull", new BsonArray { "$RecentEarnings", new BsonArray() }) },
|
||||
{ "as", "earning" },
|
||||
{ "in", new BsonDocument("$ifNull", new BsonArray { "$$earning.CoinAmount", 1 }) }
|
||||
})),
|
||||
maximum
|
||||
})));
|
||||
|
||||
private Task MarkLedgerAppliedAsync(CreditLedgerEntry entry, int amount, DateTime now) =>
|
||||
_ledger.UpdateOneAsync(value => value.Id == entry.Id,
|
||||
Builders<CreditLedgerEntry>.Update.Set(value => value.Amount, amount)
|
||||
.Set(value => value.State, "applied").Set(value => value.AppliedUtc, now));
|
||||
|
||||
private Task RecordPurchaseLedgerAsync(string userId, RzStoreProduct product, string operationId, DateTime createdUtc) =>
|
||||
_ledger.ReplaceOneAsync(
|
||||
value => value.Id == LedgerId(userId, $"purchase:{operationId}"),
|
||||
new CreditLedgerEntry
|
||||
{
|
||||
Id = LedgerId(userId, $"purchase:{operationId}"), UserId = userId,
|
||||
OperationId = $"purchase:{operationId}", RequestedAmount = -product.CoinPrice,
|
||||
Amount = -product.CoinPrice, ReasonCode = CreditReasonCodes.Purchase,
|
||||
Description = $"Purchased {product.Name}", SourceType = "store_product",
|
||||
SourceId = product.Sku, State = "applied", CreatedUtc = createdUtc, AppliedUtc = createdUtc
|
||||
},
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
|
||||
private static CreditAwardResult Result(CreditLedgerEntry entry, CreditStatusResponse status) => new()
|
||||
{
|
||||
Award = entry.Amount <= 0 ? null : new CreditAward
|
||||
{
|
||||
Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description
|
||||
},
|
||||
Status = status
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class MasteryAwardService
|
||||
{
|
||||
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
||||
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
|
||||
private readonly IMongoCollection<ReviewQueueItem> _queue;
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public MasteryAwardService(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits, TimeProvider clock)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_reviews = mongo.Database.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
|
||||
_queue = mongo.Database.GetCollection<ReviewQueueItem>("ReviewQueueItems");
|
||||
_questions = questions;
|
||||
_credits = credits;
|
||||
_clock = clock;
|
||||
}
|
||||
|
||||
public async Task PrepareAsync(string userId)
|
||||
{
|
||||
if (await _credits.IsMasteryBaselineAppliedAsync(userId)) return;
|
||||
var states = await CurrentStatesAsync(userId);
|
||||
await _credits.InitializeMasteryBaselineAsync(userId, states.SelectMany(BaselineOperations));
|
||||
}
|
||||
|
||||
public async Task<List<CreditAward>> AwardPromotionsAsync(string userId)
|
||||
{
|
||||
var awards = new List<CreditAward>();
|
||||
var applied = await _credits.GetAppliedOperationsAsync(userId);
|
||||
foreach (var system in await CurrentStatesAsync(userId))
|
||||
{
|
||||
foreach (var state in EarnedStates(system.MasteryState))
|
||||
{
|
||||
var spec = AwardSpec(state);
|
||||
if (applied.Contains(Operation(system.System, state))) continue;
|
||||
var result = await _credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
Operation(system.System, state), spec.Amount, spec.Reason, spec.Description,
|
||||
"step3_system", system.System, false));
|
||||
if (result.Award is not null) awards.Add(result.Award);
|
||||
applied.Add(Operation(system.System, state));
|
||||
}
|
||||
}
|
||||
return awards;
|
||||
}
|
||||
|
||||
private async Task<List<DashboardSystemPerformance>> CurrentStatesAsync(string userId)
|
||||
{
|
||||
var attempts = await _attempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
var reviews = await _reviews.Find(value => value.UserId == userId).ToListAsync();
|
||||
var queue = await _queue.Find(value => value.UserId == userId).ToListAsync();
|
||||
var ids = attempts.Select(value => value.QuestionExternalId)
|
||||
.Concat(reviews.Select(value => value.QuestionExternalId))
|
||||
.Concat(queue.Select(value => value.QuestionExternalId));
|
||||
var questions = await _questions.GetByExternalIdsAsync(ids);
|
||||
return MasteryCalculator.Calculate(attempts, reviews, queue, questions, _clock.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BaselineOperations(DashboardSystemPerformance system) =>
|
||||
EarnedStates(system.MasteryState).Select(state => Operation(system.System, state));
|
||||
|
||||
private static IEnumerable<string> EarnedStates(string state)
|
||||
{
|
||||
if (state is MasteryStates.Practicing or MasteryStates.Strong or MasteryStates.Mastered)
|
||||
yield return MasteryStates.Practicing;
|
||||
if (state is MasteryStates.Strong or MasteryStates.Mastered)
|
||||
yield return MasteryStates.Strong;
|
||||
if (state is MasteryStates.Mastered)
|
||||
yield return MasteryStates.Mastered;
|
||||
}
|
||||
|
||||
private static string Operation(string system, string state) =>
|
||||
$"mastery:{system.ToLowerInvariant().Replace(' ', '_')}:{state}";
|
||||
|
||||
private static (int Amount, string Reason, string Description) AwardSpec(string state) => state switch
|
||||
{
|
||||
MasteryStates.Practicing => (3, CreditReasonCodes.MasteryPracticing, "System advanced to Practicing"),
|
||||
MasteryStates.Strong => (5, CreditReasonCodes.MasteryStrong, "System advanced to Strong"),
|
||||
_ => (10, CreditReasonCodes.MasteryMastered, "System advanced to Mastered")
|
||||
};
|
||||
}
|
||||
@@ -17,6 +17,10 @@ public sealed class ServiceMetrics
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicAttempts = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicCorrect = new();
|
||||
private long _dynamicTargetGapTotal;
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditAwards = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCoins = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditDuplicates = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCapped = new();
|
||||
|
||||
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
|
||||
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
|
||||
@@ -43,6 +47,13 @@ public sealed class ServiceMetrics
|
||||
_dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1);
|
||||
if (correct) _dynamicCorrect.AddOrUpdate(tier, 1, (_, value) => value + 1);
|
||||
}
|
||||
public void CreditAward(string reason, int amount)
|
||||
{
|
||||
_creditAwards.AddOrUpdate(reason, 1, (_, value) => value + 1);
|
||||
_creditCoins.AddOrUpdate(reason, amount, (_, value) => value + amount);
|
||||
}
|
||||
public void CreditDuplicate(string reason) => _creditDuplicates.AddOrUpdate(reason, 1, (_, value) => value + 1);
|
||||
public void CreditCapped(string reason) => _creditCapped.AddOrUpdate(reason, 1, (_, value) => value + 1);
|
||||
|
||||
public string RenderPrometheus()
|
||||
{
|
||||
@@ -63,6 +74,17 @@ public sealed class ServiceMetrics
|
||||
AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier));
|
||||
}
|
||||
AppendCounter(output, "step3_dynamic_target_gap_points_total", Interlocked.Read(ref _dynamicTargetGapTotal));
|
||||
output.AppendLine("# TYPE step3_credit_awards_total counter");
|
||||
output.AppendLine("# TYPE step3_credit_coins_awarded_total counter");
|
||||
output.AppendLine("# TYPE step3_credit_award_duplicates_total counter");
|
||||
output.AppendLine("# TYPE step3_credit_award_capped_total counter");
|
||||
foreach (var reason in _creditAwards.Keys.Concat(_creditDuplicates.Keys).Concat(_creditCapped.Keys).Distinct().Order())
|
||||
{
|
||||
AppendReasonSample(output, "step3_credit_awards_total", reason, _creditAwards.GetValueOrDefault(reason));
|
||||
AppendReasonSample(output, "step3_credit_coins_awarded_total", reason, _creditCoins.GetValueOrDefault(reason));
|
||||
AppendReasonSample(output, "step3_credit_award_duplicates_total", reason, _creditDuplicates.GetValueOrDefault(reason));
|
||||
AppendReasonSample(output, "step3_credit_award_capped_total", reason, _creditCapped.GetValueOrDefault(reason));
|
||||
}
|
||||
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
|
||||
output.Append("step3_http_request_duration_seconds_count ")
|
||||
.Append(Interlocked.Read(ref _httpRequests))
|
||||
@@ -91,4 +113,7 @@ public sealed class ServiceMetrics
|
||||
{
|
||||
output.Append(name).Append("{tier=\"").Append(tier.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
|
||||
}
|
||||
|
||||
private static void AppendReasonSample(StringBuilder output, string name, string reason, long value) =>
|
||||
output.Append(name).Append("{reason=\"").Append(reason.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserCreditAccounts"
|
||||
"UserCreditAccounts", "CreditLedgerEntries"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
Reference in New Issue
Block a user