259 lines
13 KiB
C#
259 lines
13 KiB
C#
using MongoDB.Driver;
|
|
using QuestionsApi.Models;
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
public sealed class AccessStore
|
|
{
|
|
private readonly IMongoCollection<UserAccessAccount> _accounts;
|
|
private readonly TimeProvider _time;
|
|
|
|
public AccessStore(MongoDatabaseProvider mongo, TimeProvider time)
|
|
{
|
|
_accounts = mongo.Database.GetCollection<UserAccessAccount>("UserAccessAccounts");
|
|
_time = time;
|
|
_accounts.Indexes.CreateOne(new CreateIndexModel<UserAccessAccount>(
|
|
Builders<UserAccessAccount>.IndexKeys.Ascending(value => value.UserId),
|
|
new CreateIndexOptions { Unique = true, Name = "uq_access_user" }));
|
|
}
|
|
|
|
public async Task<AccessStatusResponse> 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<int> 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<bool> CanLoadQuestionAsync(string userId, bool bypass)
|
|
{
|
|
if (bypass) return true;
|
|
var status = await GetStatusAsync(userId);
|
|
return status.Unlimited || status.QuestionsRemaining > 0;
|
|
}
|
|
|
|
public async Task<bool> 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<UserAccessAccount>.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<UserAccessAccount>(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<UserAccessAccount>.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<bool> TryAwardCreditAsync(string userId, string operationId)
|
|
{
|
|
var now = _time.GetUtcNow().UtcDateTime;
|
|
await EnsureAndCleanAsync(userId, now);
|
|
var filters = Builders<UserAccessAccount>.Filter;
|
|
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(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<UserAccessAccount>.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<AccessStatusResponse?> RedeemCreditsAsync(string userId, string operationId) =>
|
|
SpendAndExtendAsync(userId, operationId, AccessCatalog.DayPassCreditCost, null, TimeSpan.FromHours(24));
|
|
|
|
public Task<AccessStatusResponse?> 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<AccessStatusResponse?>(null)
|
|
};
|
|
|
|
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
|
{
|
|
var now = _time.GetUtcNow().UtcDateTime;
|
|
await EnsureAndCleanAsync(userId, now);
|
|
var filters = Builders<UserAccessAccount>.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<UserAccessAccount>.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<UserAccessAccount>.Filter.Eq(value => value.UserId, userId) &
|
|
Builders<UserAccessAccount>.Filter.AnyEq(value => value.AppliedOperationIds, operationId);
|
|
await _accounts.UpdateOneAsync(filter, Builders<UserAccessAccount>.Update
|
|
.Inc(value => value.ExamTokens, 1)
|
|
.Pull(value => value.AppliedOperationIds, operationId)
|
|
.Set(value => value.UpdatedUtc, now));
|
|
}
|
|
|
|
public async Task<int?> ApplyResetAsync(string userId, string operationId)
|
|
{
|
|
var now = _time.GetUtcNow().UtcDateTime;
|
|
await EnsureAndCleanAsync(userId, now);
|
|
var filters = Builders<UserAccessAccount>.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<UserAccessAccount>.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<UserAccessAccount> { ReturnDocument = ReturnDocument.After });
|
|
return updated?.BankCycle;
|
|
}
|
|
|
|
public async Task<bool> ApplyProductGrantAsync(string userId, AccessProduct product, string operationId)
|
|
{
|
|
var now = _time.GetUtcNow().UtcDateTime;
|
|
await EnsureAndCleanAsync(userId, now);
|
|
var filters = Builders<UserAccessAccount>.Filter;
|
|
var filter = filters.Eq(value => value.UserId, userId) &
|
|
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
|
var update = Builders<UserAccessAccount>.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<AccessStatusResponse?> 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<UserAccessAccount>.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<UserAccessAccount>.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<UserAccessAccount>.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<UserAccessAccount>.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<UserAccessAccount>.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()
|
|
};
|
|
}
|
|
}
|