diff --git a/api/QuestionsApi.Tests/CreditEconomyTests.cs b/api/QuestionsApi.Tests/CreditEconomyTests.cs new file mode 100644 index 0000000..c63772b --- /dev/null +++ b/api/QuestionsApi.Tests/CreditEconomyTests.cs @@ -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))); + } +} diff --git a/api/QuestionsApi/Controllers/CreditsController.cs b/api/QuestionsApi/Controllers/CreditsController.cs new file mode 100644 index 0000000..198fe60 --- /dev/null +++ b/api/QuestionsApi/Controllers/CreditsController.cs @@ -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 Get() => Ok(await credits.GetStatusAsync(UserId())); + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) + ?? User.Identity?.Name + ?? throw new UnauthorizedAccessException(); +} diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 221be3d..3df47a1 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -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) }); } diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs new file mode 100644 index 0000000..50f4aea --- /dev/null +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -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 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; +} diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 0c636ba..97e70fb 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -141,6 +141,7 @@ public class FirstQuestionAttemptResponse public Dictionary ChoiceSelectionPercentages { get; set; } = []; public int QuestionElo { get; set; } = 1000; public RatingChangeResponse? Rating { get; set; } + public CreditStatusResponse? Credits { get; set; } } public class SkipQuestionResponse diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index c04924b..7daeda7 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -66,6 +66,8 @@ builder.Services.Configure(options => }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/Services/CreditStore.cs b/api/QuestionsApi/Services/CreditStore.cs new file mode 100644 index 0000000..5914ed0 --- /dev/null +++ b/api/QuestionsApi/Services/CreditStore.cs @@ -0,0 +1,78 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class CreditStore +{ + private readonly IMongoCollection _accounts; + private readonly TimeProvider _clock; + + public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock) + { + _accounts = mongo.Database.GetCollection("UserCreditAccounts"); + _clock = clock; + _accounts.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId), + new CreateIndexOptions { Unique = true, Name = "uq_credit_user" })); + } + + public async Task AwardForFirstAnswerAsync(string userId, string attemptId) + { + if (string.IsNullOrWhiteSpace(attemptId)) return false; + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + + var filters = Builders.Filter; + var belowCap = new BsonDocumentFilterDefinition( + 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.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 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.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.Update.PullFilter( + value => value.RecentEarnings, + earning => earning.CreatedUtc <= cutoff)); + } +} diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 2e3c086..50f9afe 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -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) diff --git a/docs/QUESTION_COMPLETION_CREDITS.md b/docs/QUESTION_COMPLETION_CREDITS.md new file mode 100644 index 0000000..93f2dc3 --- /dev/null +++ b/docs/QUESTION_COMPLETION_CREDITS.md @@ -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.