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.Credits, 0) .SetOnInsert(value => value.RecentEarnings, []) .SetOnInsert(value => value.UpdatedUtc, now), new UpdateOptions { IsUpsert = true }); await _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false), Builders.Update.Set(value => value.RecentEarnings, [])); var cutoff = now - CreditEconomy.EarningWindow; await _accounts.UpdateOneAsync( value => value.UserId == userId, Builders.Update.PullFilter( value => value.RecentEarnings, earning => earning.CreatedUtc <= cutoff)); } }