141 lines
6.1 KiB
C#
141 lines
6.1 KiB
C#
using MongoDB.Driver;
|
|
using QuestionsApi.Models;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace QuestionsApi.Services;
|
|
|
|
public class QuestionAttemptStore
|
|
{
|
|
private readonly IMongoCollection<QuestionAttempt> _col;
|
|
|
|
public const int CounterBucketCount = 32;
|
|
|
|
public QuestionAttemptStore(MongoDatabaseProvider mongo)
|
|
{
|
|
_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.BankCycle);
|
|
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
|
firstAttemptIndex,
|
|
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));
|
|
|
|
var createdIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.CreatedUtc);
|
|
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(createdIndex));
|
|
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
|
Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.UserId).Descending(a => a.CreatedUtc)));
|
|
}
|
|
|
|
public async Task<QuestionAttemptRecordResult> RecordFirstAttemptAsync(
|
|
string questionExternalId,
|
|
string userId,
|
|
string choiceId,
|
|
bool isCorrect,
|
|
bool isSkip = false,
|
|
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
|
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 },
|
|
IsCorrect = isCorrect,
|
|
IsSkip = isSkip,
|
|
DurationSeconds = Math.Clamp(durationSeconds, 0, 86_400),
|
|
CounterBucket = GetCounterBucket(userId),
|
|
RatingApplied = false,
|
|
CreatedUtc = DateTime.UtcNow
|
|
};
|
|
|
|
try
|
|
{
|
|
await _col.InsertOneAsync(attempt);
|
|
return new QuestionAttemptRecordResult { Inserted = true, Attempt = attempt };
|
|
}
|
|
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
|
{
|
|
var existing = await _col
|
|
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId && a.BankCycle == bankCycle)
|
|
.FirstAsync();
|
|
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
|
}
|
|
}
|
|
|
|
public Task<List<string>> GetActiveQuestionIdsAsync(DateTime sinceUtc) =>
|
|
_col.Distinct(
|
|
attempt => attempt.QuestionExternalId,
|
|
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
|
.ToListAsync();
|
|
|
|
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) =>
|
|
_col.Distinct(
|
|
attempt => attempt.QuestionExternalId,
|
|
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;
|
|
|
|
public async Task<bool> TryAcquireRatingLeaseAsync(string attemptId, string owner, TimeSpan duration)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
var filter = Builders<QuestionAttempt>.Filter.And(
|
|
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.Id, attemptId),
|
|
Builders<QuestionAttempt>.Filter.Exists(attempt => attempt.RatingApplied, true),
|
|
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.RatingApplied, false),
|
|
Builders<QuestionAttempt>.Filter.Or(
|
|
Builders<QuestionAttempt>.Filter.Lt(attempt => attempt.RatingLeaseUntilUtc, now),
|
|
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.RatingLeaseOwner, owner)));
|
|
var update = Builders<QuestionAttempt>.Update
|
|
.Set(attempt => attempt.RatingLeaseOwner, owner)
|
|
.Set(attempt => attempt.RatingLeaseUntilUtc, now.Add(duration));
|
|
var leased = await _col.FindOneAndUpdateAsync(
|
|
filter,
|
|
update,
|
|
new FindOneAndUpdateOptions<QuestionAttempt> { ReturnDocument = ReturnDocument.After });
|
|
return leased?.RatingLeaseOwner == owner;
|
|
}
|
|
|
|
public Task MarkRatingAppliedAsync(string attemptId, string owner, int userOverallElo) =>
|
|
_col.UpdateOneAsync(
|
|
attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner,
|
|
Builders<QuestionAttempt>.Update
|
|
.Set(attempt => attempt.RatingApplied, true)
|
|
.Set(attempt => attempt.UserOverallEloAfter, userOverallElo)
|
|
.Set(attempt => attempt.RatingAppliedUtc, DateTime.UtcNow)
|
|
.Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue));
|
|
|
|
public Task ReleaseRatingLeaseAsync(string attemptId, string owner) =>
|
|
_col.UpdateOneAsync(
|
|
attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner && attempt.RatingApplied == false,
|
|
Builders<QuestionAttempt>.Update.Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue));
|
|
|
|
public static int GetCounterBucket(string userId)
|
|
{
|
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(userId));
|
|
return (int)(BitConverter.ToUInt32(hash, 0) % CounterBucketCount);
|
|
}
|
|
}
|