Add isolated question completion credits

This commit is contained in:
hz
2026-07-28 18:40:49 -05:00
parent 1d5d03a4f6
commit 22b0df7aed
9 changed files with 186 additions and 3 deletions
@@ -0,0 +1,21 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class CreditEconomyTests
{
[Fact]
public void EarningPolicy_UsesTheAgreedRollingLimit()
{
Assert.Equal(20, CreditEconomy.RollingEarningLimit);
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow);
}
[Fact]
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
{
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings)));
}
}
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/credits")]
[Authorize(Roles = "USER,SUPER")]
public sealed class CreditsController(CreditStore credits) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
@@ -19,6 +19,7 @@ public class QuestionsController : ControllerBase
private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits;
public QuestionsController(
QuestionStore questions,
@@ -28,7 +29,8 @@ public class QuestionsController : ControllerBase
AnswerStatisticsStore statistics,
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue)
ReviewQueueStore reviewQueue,
CreditStore credits)
{
_questions = questions;
_attempts = attempts;
@@ -38,6 +40,7 @@ public class QuestionsController : ControllerBase
_metrics = metrics;
_bookmarks = bookmarks;
_reviewQueue = reviewQueue;
_credits = credits;
}
[HttpGet("systems")]
@@ -172,6 +175,8 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate();
}
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && !User.IsInRole("SUPER"))
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -204,7 +209,8 @@ public class QuestionsController : ControllerBase
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating
Rating = rating,
Credits = await _credits.GetStatusAsync(userId)
});
}
+35
View File
@@ -0,0 +1,35 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class CreditEconomy
{
public const int RollingEarningLimit = 20;
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
}
public sealed class CreditEvent
{
public string OperationId { get; set; } = "";
public DateTime CreatedUtc { get; set; }
}
public sealed class UserCreditAccount
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public int Credits { get; set; }
public List<CreditEvent> RecentEarnings { get; set; } = [];
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class CreditStatusResponse
{
public int Credits { get; set; }
public int EarnedInRollingWindow { get; set; }
public int RemainingEarningCapacity { get; set; }
public int RollingWindowHours { get; set; } = 24;
}
@@ -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 CreditStatusResponse? Credits { get; set; }
}
public class SkipQuestionResponse
+2
View File
@@ -66,6 +66,8 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
});
builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CreditStore>();
builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<AnswerStatisticsStore>();
+78
View File
@@ -0,0 +1,78 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CreditStore
{
private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly TimeProvider _clock;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_clock = clock;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
}
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
{
if (string.IsNullOrWhiteSpace(attemptId)) return false;
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
{
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,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return new CreditStatusResponse
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count,
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
};
}
private async Task EnsureAndCleanAsync(string userId, DateTime now)
{
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
}
}
@@ -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",
"UserCreditAccounts"
];
public UserProfileStore(MongoDatabaseProvider mongo)
+19
View File
@@ -0,0 +1,19 @@
# Question-completion credits
This branch introduces credit earning only. It does not impose a question
limit, redeem credits, sell products, activate passes, consume exam tokens, or
reset QBank progress.
- A successfully stored first answer earns one credit regardless of whether it
is correct.
- A repeated answer does not earn another credit.
- A skipped question does not earn credit.
- At most 20 credits can be earned in a rolling 24-hour window.
- Historical attempts are not backfilled.
- `GET /api/credits` returns the authenticated user's balance and current
rolling-window capacity.
- The first-attempt response also includes the updated credit status.
The API is authoritative. Credit grants use the immutable attempt ID as their
idempotency key, so retrying the same completed question cannot grant another
credit. Credit accounts are included in account export and deletion.