Files
step3/api/QuestionsApi/Services/QuestionAttemptStore.cs
T
2026-07-29 18:34:54 -05:00

136 lines
5.9 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");
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)
{
var attempt = new QuestionAttempt
{
QuestionExternalId = questionExternalId,
UserId = userId,
BankCycle = bankCycle,
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)
{
var filter = Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId);
if (bankCycle.HasValue)
filter &= Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value);
return
_col.Distinct(
attempt => attempt.QuestionExternalId,
filter)
.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);
}
}