Add freemium access economy and token store
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AccessPurchaseStore
|
||||
{
|
||||
private readonly IMongoCollection<AccessPurchase> _purchases;
|
||||
|
||||
public AccessPurchaseStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_purchases = mongo.Database.GetCollection<AccessPurchase>("AccessPurchases");
|
||||
_purchases.Indexes.CreateMany([
|
||||
new CreateIndexModel<AccessPurchase>(
|
||||
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)),
|
||||
new CreateIndexModel<AccessPurchase>(
|
||||
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.ProviderSessionId),
|
||||
new CreateIndexOptions<AccessPurchase>
|
||||
{
|
||||
Unique = true,
|
||||
PartialFilterExpression = Builders<AccessPurchase>.Filter.Ne(value => value.ProviderSessionId, "")
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
public async Task<AccessPurchase> CreateAsync(string userId, AccessProduct product)
|
||||
{
|
||||
var purchase = new AccessPurchase
|
||||
{
|
||||
UserId = userId,
|
||||
Sku = product.Sku,
|
||||
AmountCents = product.AmountCents,
|
||||
Currency = product.Currency
|
||||
};
|
||||
await _purchases.InsertOneAsync(purchase);
|
||||
return purchase;
|
||||
}
|
||||
|
||||
public async Task<AccessPurchase?> GetOwnedAsync(string id, string userId) =>
|
||||
await _purchases.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<AccessPurchase?> GetAsync(string id) =>
|
||||
await _purchases.Find(value => value.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public Task AttachSessionAsync(string id, string sessionId) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.ProviderSessionId, sessionId)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public Task MarkCheckoutFailedAsync(string id) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.Status, AccessPurchaseStatus.Failed)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public async Task<AccessPurchase?> ApplyProviderStatusAsync(
|
||||
string id, string sessionId, long amountCents, string currency,
|
||||
string status, string paymentId, string eventId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(sessionId)) return null;
|
||||
var filters = Builders<AccessPurchase>.Filter;
|
||||
var filter = filters.Eq(value => value.Id, id) &
|
||||
filters.Eq(value => value.AmountCents, amountCents) &
|
||||
filters.Eq(value => value.Currency, currency.ToLowerInvariant()) &
|
||||
(filters.Eq(value => value.ProviderSessionId, sessionId) |
|
||||
filters.Eq(value => value.ProviderSessionId, "")) &
|
||||
filters.Not(filters.AnyEq(value => value.ProviderEventIds, eventId));
|
||||
var update = Builders<AccessPurchase>.Update
|
||||
.Set(value => value.Status, status)
|
||||
.Set(value => value.ProviderSessionId, sessionId)
|
||||
.Set(value => value.ProviderPaymentId, paymentId)
|
||||
.AddToSet(value => value.ProviderEventIds, eventId)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow);
|
||||
return await _purchases.FindOneAndUpdateAsync(filter, update,
|
||||
new FindOneAndUpdateOptions<AccessPurchase> { ReturnDocument = ReturnDocument.After });
|
||||
}
|
||||
|
||||
public Task MarkGrantAppliedAsync(string id) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Paid,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.GrantApplied, true)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public static AccessPurchaseStatusResponse ToStatus(AccessPurchase purchase) => new()
|
||||
{
|
||||
PurchaseId = purchase.Id ?? "",
|
||||
Sku = purchase.Sku,
|
||||
AmountCents = purchase.AmountCents,
|
||||
Currency = purchase.Currency,
|
||||
Status = purchase.Status
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public sealed class DashboardStore
|
||||
private readonly FlashcardScheduleStore _flashcardSchedules;
|
||||
private readonly RatingStore _ratingStore;
|
||||
private readonly CcsRatingStore _ccsRatingStore;
|
||||
private readonly AccessStore _accessStore;
|
||||
private readonly IMongoCollection<UserRating> _ratings;
|
||||
|
||||
public static readonly string[] Step3Systems =
|
||||
@@ -48,7 +49,8 @@ public sealed class DashboardStore
|
||||
FlashcardStudyService flashcards,
|
||||
FlashcardScheduleStore flashcardSchedules,
|
||||
RatingStore ratingStore,
|
||||
CcsRatingStore ccsRatingStore)
|
||||
CcsRatingStore ccsRatingStore,
|
||||
AccessStore accessStore)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||
@@ -65,6 +67,7 @@ public sealed class DashboardStore
|
||||
_flashcardSchedules = flashcardSchedules;
|
||||
_ratingStore = ratingStore;
|
||||
_ccsRatingStore = ccsRatingStore;
|
||||
_accessStore = accessStore;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
@@ -72,7 +75,9 @@ public sealed class DashboardStore
|
||||
var today = nowUtc.Date;
|
||||
var trendStart = today.AddDays(-29);
|
||||
var weekStart = today.AddDays(-6);
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
var bankCycleTask = _accessStore.GetBankCycleAsync(userId);
|
||||
var bankCycle = await bankCycleTask;
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId && value.BankCycle == bankCycle).ToListAsync();
|
||||
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
|
||||
@@ -15,12 +15,20 @@ public class QuestionAttemptStore
|
||||
{
|
||||
_col = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
|
||||
try
|
||||
{
|
||||
_col.Indexes.DropOne("QuestionExternalId_1_UserId_1");
|
||||
}
|
||||
catch (MongoCommandException exception) when (exception.Code is 26 or 27)
|
||||
{
|
||||
}
|
||||
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(a => a.QuestionExternalId)
|
||||
.Ascending(a => a.UserId);
|
||||
.Ascending(a => a.UserId)
|
||||
.Ascending(a => a.BankCycle);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||
firstAttemptIndex,
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
|
||||
|
||||
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
@@ -38,12 +46,16 @@ public class QuestionAttemptStore
|
||||
bool isCorrect,
|
||||
bool isSkip = false,
|
||||
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||
int durationSeconds = 0)
|
||||
int durationSeconds = 0,
|
||||
int bankCycle = 0,
|
||||
string completionId = "")
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
QuestionExternalId = questionExternalId,
|
||||
UserId = userId,
|
||||
BankCycle = bankCycle,
|
||||
CompletionId = completionId,
|
||||
ChoiceId = choiceId,
|
||||
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
|
||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||
@@ -63,7 +75,7 @@ public class QuestionAttemptStore
|
||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
var existing = await _col
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
|
||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId && a.BankCycle == bankCycle)
|
||||
.FirstAsync();
|
||||
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
||||
}
|
||||
@@ -75,10 +87,13 @@ public class QuestionAttemptStore
|
||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) =>
|
||||
_col.Distinct(
|
||||
attempt => attempt.QuestionExternalId,
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId) &
|
||||
(bankCycle is null
|
||||
? FilterDefinition<QuestionAttempt>.Empty
|
||||
: Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value)))
|
||||
.ToListAsync();
|
||||
|
||||
public IMongoCollection<QuestionAttempt> Collection => _col;
|
||||
|
||||
@@ -41,6 +41,18 @@ public class RatingStore
|
||||
return await EnsureHistoryBaselineAsync(rating, nowUtc);
|
||||
}
|
||||
|
||||
public Task ResetUserAsync(string userId, DateTime nowUtc) =>
|
||||
_users.UpdateOneAsync(
|
||||
rating => rating.UserId == userId,
|
||||
Builders<UserRating>.Update
|
||||
.Set(rating => rating.OverallElo, BaseElo)
|
||||
.Set(rating => rating.RatedQuestionCount, 0)
|
||||
.Set(rating => rating.CategoryElos, DefaultCategoryElos())
|
||||
.Set(rating => rating.HistoryBaselineElo, null)
|
||||
.Set(rating => rating.HistoryStartedUtc, null)
|
||||
.Set(rating => rating.UpdatedUtc, nowUtc),
|
||||
new UpdateOptions { IsUpsert = false });
|
||||
|
||||
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
|
||||
{
|
||||
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using QuestionsApi.Models;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public interface IStripeAccessGateway
|
||||
{
|
||||
Task<StripeCheckoutResult> CreateCheckoutAsync(AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class StripeAccessGateway(IConfiguration configuration) : IStripeAccessGateway
|
||||
{
|
||||
public async Task<StripeCheckoutResult> CreateCheckoutAsync(
|
||||
AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken)
|
||||
{
|
||||
var secretKey = configuration["Stripe:SecretKey"];
|
||||
if (string.IsNullOrWhiteSpace(secretKey)) throw new InvalidOperationException("Stripe secret key is not configured.");
|
||||
var successUrl = configuration["Stripe:AccessSuccessUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}";
|
||||
var cancelUrl = configuration["Stripe:AccessCancelUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-canceled.html";
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["access_purchase_id"] = purchase.Id ?? "",
|
||||
["access_sku"] = product.Sku,
|
||||
["user_id"] = purchase.UserId
|
||||
};
|
||||
var service = new SessionService(new StripeClient(secretKey));
|
||||
var session = await service.CreateAsync(new SessionCreateOptions
|
||||
{
|
||||
Mode = "payment",
|
||||
SuccessUrl = successUrl,
|
||||
CancelUrl = cancelUrl,
|
||||
ClientReferenceId = purchase.Id,
|
||||
CustomerCreation = "always",
|
||||
BillingAddressCollection = "auto",
|
||||
AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true },
|
||||
Metadata = metadata,
|
||||
PaymentIntentData = new SessionPaymentIntentDataOptions { Metadata = metadata },
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions
|
||||
{
|
||||
Quantity = 1,
|
||||
PriceData = new SessionLineItemPriceDataOptions
|
||||
{
|
||||
Currency = product.Currency,
|
||||
UnitAmount = product.AmountCents,
|
||||
ProductData = new SessionLineItemPriceDataProductDataOptions
|
||||
{
|
||||
Name = product.Name,
|
||||
Description = "A manually activated RanaZe access entitlement."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}, null, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(session.Id) || string.IsNullOrWhiteSpace(session.Url))
|
||||
throw new InvalidOperationException("Stripe did not return a checkout URL.");
|
||||
return new StripeCheckoutResult(session.Id, session.Url);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public sealed class UserProfileStore
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserAccessAccounts", "AccessPurchases"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
Reference in New Issue
Block a user