Add isolated question completion credits
This commit is contained in:
@@ -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 ServiceMetrics _metrics;
|
||||||
private readonly QuestionBookmarkStore _bookmarks;
|
private readonly QuestionBookmarkStore _bookmarks;
|
||||||
private readonly ReviewQueueStore _reviewQueue;
|
private readonly ReviewQueueStore _reviewQueue;
|
||||||
|
private readonly CreditStore _credits;
|
||||||
|
|
||||||
public QuestionsController(
|
public QuestionsController(
|
||||||
QuestionStore questions,
|
QuestionStore questions,
|
||||||
@@ -28,7 +29,8 @@ public class QuestionsController : ControllerBase
|
|||||||
AnswerStatisticsStore statistics,
|
AnswerStatisticsStore statistics,
|
||||||
ServiceMetrics metrics,
|
ServiceMetrics metrics,
|
||||||
QuestionBookmarkStore bookmarks,
|
QuestionBookmarkStore bookmarks,
|
||||||
ReviewQueueStore reviewQueue)
|
ReviewQueueStore reviewQueue,
|
||||||
|
CreditStore credits)
|
||||||
{
|
{
|
||||||
_questions = questions;
|
_questions = questions;
|
||||||
_attempts = attempts;
|
_attempts = attempts;
|
||||||
@@ -38,6 +40,7 @@ public class QuestionsController : ControllerBase
|
|||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
_bookmarks = bookmarks;
|
_bookmarks = bookmarks;
|
||||||
_reviewQueue = reviewQueue;
|
_reviewQueue = reviewQueue;
|
||||||
|
_credits = credits;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("systems")]
|
[HttpGet("systems")]
|
||||||
@@ -172,6 +175,8 @@ public class QuestionsController : ControllerBase
|
|||||||
_metrics.AttemptDuplicate();
|
_metrics.AttemptDuplicate();
|
||||||
}
|
}
|
||||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
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 ratingOwner = Guid.NewGuid().ToString("N");
|
||||||
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
|
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
|
||||||
@@ -204,7 +209,8 @@ public class QuestionsController : ControllerBase
|
|||||||
question,
|
question,
|
||||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||||
QuestionElo = rating.QuestionElo,
|
QuestionElo = rating.QuestionElo,
|
||||||
Rating = rating
|
Rating = rating,
|
||||||
|
Credits = await _credits.GetStatusAsync(userId)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||||
public int QuestionElo { get; set; } = 1000;
|
public int QuestionElo { get; set; } = 1000;
|
||||||
public RatingChangeResponse? Rating { get; set; }
|
public RatingChangeResponse? Rating { get; set; }
|
||||||
|
public CreditStatusResponse? Credits { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SkipQuestionResponse
|
public class SkipQuestionResponse
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddSingleton<MongoDatabaseProvider>();
|
builder.Services.AddSingleton<MongoDatabaseProvider>();
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
builder.Services.AddSingleton<CreditStore>();
|
||||||
builder.Services.AddSingleton<QuestionStore>();
|
builder.Services.AddSingleton<QuestionStore>();
|
||||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||||
builder.Services.AddSingleton<AnswerStatisticsStore>();
|
builder.Services.AddSingleton<AnswerStatisticsStore>();
|
||||||
|
|||||||
@@ -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",
|
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||||
"ExternalIdentities", "ExternalAuthTransactions",
|
"ExternalIdentities", "ExternalAuthTransactions",
|
||||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
|
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||||
|
"UserCreditAccounts"
|
||||||
];
|
];
|
||||||
|
|
||||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user