using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public sealed class AccessStore { private readonly IMongoCollection _accounts; private readonly TimeProvider _time; public AccessStore(MongoDatabaseProvider mongo, TimeProvider time) { _accounts = mongo.Database.GetCollection("UserAccessAccounts"); _time = time; _accounts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId), new CreateIndexOptions { Unique = true, Name = "uq_access_user" })); } public async Task GetStatusAsync(string userId, bool bypass = false) { var now = _time.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); return ToStatus(account, now, bypass); } public async Task GetBankCycleAsync(string userId) { var now = _time.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle; } public async Task CanLoadQuestionAsync(string userId, bool bypass) { if (bypass) return true; var status = await GetStatusAsync(userId); return status.Unlimited || status.QuestionsRemaining > 0; } public async Task ClaimQuestionAsync(string userId, string completionId, string questionExternalId, bool bypass) { if (bypass) return true; var now = _time.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var filters = Builders.Filter; var exactClaim = filters.ElemMatch(value => value.RecentUsage, item => item.Id == completionId && item.SubjectId == questionExternalId); if (await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync()) return true; var capacity = new BsonDocumentFilterDefinition(new MongoDB.Bson.BsonDocument( "$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray { new MongoDB.Bson.BsonDocument("$size", "$RecentUsage"), AccessCatalog.FreeQuestionLimit }))); var filter = filters.Eq(value => value.UserId, userId) & filters.Not(filters.ElemMatch(value => value.RecentUsage, item => item.Id == completionId)) & (filters.Gt(value => value.UnlimitedUntilUtc, now) | capacity); var update = Builders.Update .AddToSet(value => value.RecentUsage, new AccessEvent { Id = completionId, SubjectId = questionExternalId, CreatedUtc = now }) .Set(value => value.UpdatedUtc, now) .Inc(value => value.Revision, 1); if ((await _accounts.UpdateOneAsync(filter, update)).MatchedCount == 1) return true; return await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync(); } public async Task TryAwardCreditAsync(string userId, string operationId) { var now = _time.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var filters = Builders.Filter; var capacity = new BsonDocumentFilterDefinition(new MongoDB.Bson.BsonDocument( "$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray { new MongoDB.Bson.BsonDocument("$size", "$RecentCredits"), AccessCatalog.CreditEarningLimit }))); var filter = filters.Eq(value => value.UserId, userId) & filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)) & capacity; var update = Builders.Update .Inc(value => value.Credits, 1) .AddToSet(value => value.AppliedOperationIds, operationId) .Push(value => value.RecentCredits, new AccessEvent { Id = operationId, CreatedUtc = now }) .Set(value => value.UpdatedUtc, now) .Inc(value => value.Revision, 1); return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; } public Task RedeemCreditsAsync(string userId, string operationId) => SpendAndExtendAsync(userId, operationId, AccessCatalog.DayPassCreditCost, null, TimeSpan.FromHours(24)); public Task ActivateTokenAsync(string userId, string operationId, string token) => token.Trim().ToLowerInvariant() switch { "day" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.DayTokens), TimeSpan.FromHours(24)), "week" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.WeekTokens), TimeSpan.FromHours(168)), "month" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.MonthTokens), TimeSpan.FromHours(720)), _ => Task.FromResult(null) }; public async Task TryConsumeExamTokenAsync(string userId, string operationId) { var now = _time.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var filters = Builders.Filter; var filter = filters.Eq(value => value.UserId, userId) & filters.Gt(value => value.ExamTokens, 0) & filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)); var update = Builders.Update .Inc(value => value.ExamTokens, -1) .AddToSet(value => value.AppliedOperationIds, operationId) .Set(value => value.UpdatedUtc, now) .Inc(value => value.Revision, 1); return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; } public async Task RefundExamTokenAsync(string userId, string operationId) { var now = _time.GetUtcNow().UtcDateTime; var filter = Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.AnyEq(value => value.AppliedOperationIds, operationId); await _accounts.UpdateOneAsync(filter, Builders.Update .Inc(value => value.ExamTokens, 1) .Pull(value => value.AppliedOperationIds, operationId) .Set(value => value.UpdatedUtc, now)); } public async Task ApplyResetAsync(string userId, string operationId) { var now = _time.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var filters = Builders.Filter; var filter = filters.Eq(value => value.UserId, userId) & filters.Gt(value => value.ResetTokens, 0) & filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)); var updated = await _accounts.FindOneAndUpdateAsync( filter, Builders.Update .Inc(value => value.ResetTokens, -1) .Inc(value => value.BankCycle, 1) .AddToSet(value => value.AppliedOperationIds, operationId) .Set(value => value.UpdatedUtc, now) .Inc(value => value.Revision, 1), new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }); return updated?.BankCycle; } public async Task ApplyProductGrantAsync(string userId, AccessProduct product, string operationId) { var now = _time.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var filters = Builders.Filter; var filter = filters.Eq(value => value.UserId, userId) & filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)); var update = Builders.Update .AddToSet(value => value.AppliedOperationIds, operationId) .Set(value => value.UpdatedUtc, now) .Inc(value => value.Revision, 1); update = product.Grant switch { AccessGrant.DayToken => update.Inc(value => value.DayTokens, 1), AccessGrant.WeekToken => update.Inc(value => value.WeekTokens, 1), AccessGrant.MonthToken => update.Inc(value => value.MonthTokens, 1), AccessGrant.ExamToken => update.Inc(value => value.ExamTokens, 1), AccessGrant.ResetToken => update.Inc(value => value.ResetTokens, 1), _ => update }; return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; } private async Task SpendAndExtendAsync( string userId, string operationId, int credits, string? tokenProperty, TimeSpan duration) { if (string.IsNullOrWhiteSpace(operationId)) return null; var now = _time.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var filters = Builders.Filter; var filter = filters.Eq(value => value.UserId, userId) & filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)); if (credits > 0) filter &= filters.Gte(value => value.Credits, credits); filter &= tokenProperty switch { nameof(UserAccessAccount.DayTokens) => filters.Gt(value => value.DayTokens, 0), nameof(UserAccessAccount.WeekTokens) => filters.Gt(value => value.WeekTokens, 0), nameof(UserAccessAccount.MonthTokens) => filters.Gt(value => value.MonthTokens, 0), _ => FilterDefinition.Empty }; var account = await _accounts.Find(filter).FirstOrDefaultAsync(); if (account is null) return null; var start = account.UnlimitedUntilUtc is { } expiry && expiry > now ? expiry : now; var update = Builders.Update .Set(value => value.UnlimitedUntilUtc, start.Add(duration)) .AddToSet(value => value.AppliedOperationIds, operationId) .Set(value => value.UpdatedUtc, now) .Inc(value => value.Revision, 1); if (credits > 0) update = update.Inc(value => value.Credits, -credits); update = tokenProperty switch { nameof(UserAccessAccount.DayTokens) => update.Inc(value => value.DayTokens, -1), nameof(UserAccessAccount.WeekTokens) => update.Inc(value => value.WeekTokens, -1), nameof(UserAccessAccount.MonthTokens) => update.Inc(value => value.MonthTokens, -1), _ => update }; var applied = await _accounts.UpdateOneAsync( filter & filters.Eq(value => value.Revision, account.Revision), update); return applied.ModifiedCount == 1 ? await GetStatusAsync(userId) : null; } 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.AddHours(-24); await _accounts.UpdateOneAsync( value => value.UserId == userId, Builders.Update .PullFilter(value => value.RecentUsage, item => item.CreatedUtc <= cutoff) .PullFilter(value => value.RecentCredits, item => item.CreatedUtc <= cutoff)); } private static AccessStatusResponse ToStatus(UserAccessAccount account, DateTime now, bool bypass) { var unlimited = bypass || account.UnlimitedUntilUtc > now; var used = account.RecentUsage.Count; return new AccessStatusResponse { QuestionsUsed = used, QuestionsRemaining = unlimited ? AccessCatalog.FreeQuestionLimit : Math.Max(0, AccessCatalog.FreeQuestionLimit - used), NextQuestionAvailableUtc = !unlimited && used >= AccessCatalog.FreeQuestionLimit ? account.RecentUsage.MinBy(value => value.CreatedUtc)?.CreatedUtc.AddHours(24) : null, Credits = account.Credits, DayTokens = account.DayTokens, WeekTokens = account.WeekTokens, MonthTokens = account.MonthTokens, ExamTokens = account.ExamTokens, ResetTokens = account.ResetTokens, BankCycle = account.BankCycle, Unlimited = unlimited, UnlimitedUntilUtc = account.UnlimitedUntilUtc, Products = AccessCatalog.Products.Values.ToList() }; } }