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"); var firstAttemptIndex = Builders.IndexKeys .Ascending(a => a.QuestionExternalId) .Ascending(a => a.UserId); _col.Indexes.CreateOne(new CreateIndexModel( firstAttemptIndex, new CreateIndexOptions { Unique = true })); 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)); } public async Task RecordFirstAttemptAsync( string questionExternalId, string userId, string choiceId, bool isCorrect, bool isSkip = false) { var attempt = new QuestionAttempt { QuestionExternalId = questionExternalId, UserId = userId, ChoiceId = choiceId, IsCorrect = isCorrect, IsSkip = isSkip, 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) .FirstAsync(); return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing }; } } public async Task> GetPercentagesForQuestionAsync(string questionExternalId) { var results = await GetPercentagesByQuestionAsync([questionExternalId]); return results.GetValueOrDefault(questionExternalId, []); } public async Task>> GetPercentagesByQuestionAsync(IEnumerable questionExternalIds) { var ids = questionExternalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList(); if (ids.Count == 0) return []; var totals = await _col.Aggregate() .Match(a => ids.Contains(a.QuestionExternalId) && !a.IsSkip) .Group( attempt => new { attempt.QuestionExternalId, attempt.ChoiceId }, group => new QuestionAttemptCounterTotal { Key = new QuestionChoiceCounterKey { QuestionExternalId = group.Key.QuestionExternalId, ChoiceId = group.Key.ChoiceId }, Count = group.Sum(_ => 1L) }) .ToListAsync(); var output = new Dictionary>(); foreach (var group in totals.GroupBy(total => total.Key.QuestionExternalId)) { var total = group.Sum(item => item.Count); if (total == 0) continue; output[group.Key] = group .ToDictionary( item => item.Key.ChoiceId, item => (int)Math.Round(item.Count * 100.0 / total)); } return output; } public Task> GetActiveQuestionIdsAsync(DateTime sinceUtc) => _col.Distinct( attempt => attempt.QuestionExternalId, Builders.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc)) .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) => _col.UpdateOneAsync( attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner, Builders.Update .Set(attempt => attempt.RatingApplied, true) .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); } }