using MongoDB.Driver; using QuestionsApi.Models; using System.Security.Cryptography; using System.Text; namespace QuestionsApi.Services; public class QuestionAttemptStore { private readonly IMongoCollection _col; public const int CounterBucketCount = 32; public QuestionAttemptStore(MongoDatabaseProvider mongo) { _col = mongo.Database.GetCollection("QuestionAttempts"); try { _col.Indexes.DropOne("QuestionExternalId_1_UserId_1"); } catch (MongoCommandException exception) when (exception.Code is 26 or 27) { } var firstAttemptIndex = Builders.IndexKeys .Ascending(a => a.QuestionExternalId) .Ascending(a => a.UserId) .Ascending(a => a.BankCycle); _col.Indexes.CreateOne(new CreateIndexModel( firstAttemptIndex, new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" })); var questionIndex = Builders.IndexKeys.Ascending(a => a.QuestionExternalId); _col.Indexes.CreateOne(new CreateIndexModel(questionIndex)); var createdIndex = Builders.IndexKeys.Ascending(a => a.CreatedUtc); _col.Indexes.CreateOne(new CreateIndexModel(createdIndex)); _col.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(a => a.UserId).Descending(a => a.CreatedUtc))); } public async Task RecordFirstAttemptAsync( string questionExternalId, string userId, string choiceId, bool isCorrect, bool isSkip = false, IReadOnlyCollection? 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> GetActiveQuestionIdsAsync(DateTime sinceUtc) => _col.Distinct( attempt => attempt.QuestionExternalId, Builders.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc)) .ToListAsync(); public Task> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) => _col.Distinct( attempt => attempt.QuestionExternalId, Builders.Filter.Eq(attempt => attempt.UserId, userId) & (bankCycle is null ? FilterDefinition.Empty : Builders.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value))) .ToListAsync(); public IMongoCollection Collection => _col; public async Task TryAcquireRatingLeaseAsync(string attemptId, string owner, TimeSpan duration) { var now = DateTime.UtcNow; var filter = Builders.Filter.And( Builders.Filter.Eq(attempt => attempt.Id, attemptId), Builders.Filter.Exists(attempt => attempt.RatingApplied, true), Builders.Filter.Eq(attempt => attempt.RatingApplied, false), Builders.Filter.Or( Builders.Filter.Lt(attempt => attempt.RatingLeaseUntilUtc, now), Builders.Filter.Eq(attempt => attempt.RatingLeaseOwner, owner))); var update = Builders.Update .Set(attempt => attempt.RatingLeaseOwner, owner) .Set(attempt => attempt.RatingLeaseUntilUtc, now.Add(duration)); var leased = await _col.FindOneAndUpdateAsync( filter, update, new FindOneAndUpdateOptions { 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.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.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); } }