Files
step3/api/QuestionsApi/Services/CreditStore.cs
T
2026-07-29 18:34:54 -05:00

216 lines
10 KiB
C#

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 ToStatus(account);
}
public async Task<int> GetBankCycleAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
}
public async Task<CreditOperationResponse> PurchaseAsync(
string userId, RzStoreProduct product, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
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);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
Builders<UserCreditAccount>.Filter.Not(
Builders<UserCreditAccount>.Filter.ElemMatch(
value => value.Purchases, purchase => purchase.OperationId == operationId));
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, -product.CoinPrice)
.Push(value => value.Purchases, new CreditPurchaseEvent
{
OperationId = operationId,
Sku = product.Sku,
CoinCost = product.CoinPrice,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
update = product.Grant == RzStoreGrant.ExamToken
? update.Inc(value => value.ExamTokens, 1)
: update.Inc(value => value.QBankResetTokens, 1);
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null) 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);
return Response(CreditOperationResult.InsufficientCredits, account);
}
public async Task<CreditOperationResponse> ApplyQBankResetAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gt(value => value.QBankResetTokens, 0) &
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.QBankResetTokens, -1)
.Inc(value => value.BankCycle, 1)
.AddToSet(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, now);
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
if (account.AppliedOperations.Contains(operationId))
return Response(CreditOperationResult.Succeeded, account);
return Response(CreditOperationResult.InsufficientTokens, account);
}
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gt(value => value.ExamTokens, 0) &
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, -1)
.AddToSet(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public Task RefundExamTokenAsync(string userId, string operationId) =>
_accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.AnyEq(value => value.AppliedOperations, operationId),
Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Pull(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, _clock.GetUtcNow().UtcDateTime));
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.Credits, 0)
.SetOnInsert(value => value.ExamTokens, 1)
.SetOnInsert(value => value.QBankResetTokens, 0)
.SetOnInsert(value => value.BankCycle, 0)
.SetOnInsert(value => value.InitialExamGrantApplied, true)
.SetOnInsert(value => value.RecentEarnings, [])
.SetOnInsert(value => value.Purchases, [])
.SetOnInsert(value => value.AppliedOperations, [])
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Ne(value => value.InitialExamGrantApplied, true),
Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Set(value => value.InitialExamGrantApplied, true));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
Builders<UserCreditAccount>.Update.Set(value => value.AppliedOperations, []));
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
}
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count,
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count),
ExamTokens = account.ExamTokens,
QBankResetTokens = account.QBankResetTokens,
BankCycle = account.BankCycle
};
private static CreditOperationResponse Response(
CreditOperationResult result, UserCreditAccount account) => new()
{
Result = result,
Status = ToStatus(account)
};
}