using MongoDB.Driver; using QuestionsApi.Models; namespace QuestionsApi.Services; public sealed class StudySessionStore { private readonly IMongoCollection _sessions; private readonly DashboardStore _dashboard; private readonly CreditStore _credits; private readonly ReviewQueueStore _reviews; private readonly TimeProvider _clock; public StudySessionStore( MongoDatabaseProvider mongo, DashboardStore dashboard, CreditStore credits, ReviewQueueStore reviews, TimeProvider clock) { _sessions = mongo.Database.GetCollection("StudySessions"); _dashboard = dashboard; _credits = credits; _reviews = reviews; _clock = clock; _sessions.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ClientSessionId), new CreateIndexOptions { Unique = true, Name = "uq_study_session_client" })); _sessions.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State), new CreateIndexOptions { Name = "study_session_user_state" })); } public async Task CreateAsync(string userId, CreateStudySessionRequest request) { Validate(request); var existing = await _sessions.Find(value => value.UserId == userId && value.ClientSessionId == request.ClientSessionId).FirstOrDefaultAsync(); if (existing is not null) return existing; var now = _clock.GetUtcNow().UtcDateTime; await CloseStaleAsync(userId, now); var activeSimulation = await _sessions.Find(value => value.UserId == userId && value.State == StudySessionStates.Active && value.GoalType == StudyGoalTypes.SimulationBlock) .AnyAsync(); if (activeSimulation) throw new InvalidOperationException("Finish the active simulation block before starting another study session."); await _sessions.UpdateManyAsync( value => value.UserId == userId && value.State == StudySessionStates.Active, Builders.Update.Set(value => value.State, StudySessionStates.Interrupted) .Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now)); var dashboard = await _dashboard.GetAsync(userId, now); var normalizedSystem = string.IsNullOrWhiteSpace(request.Step3System) ? "" : Step3SystemCatalog.Normalize(request.Step3System) ?? throw new ArgumentException("Invalid Step 3 system."); var target = request.GoalType switch { StudyGoalTypes.QuestionCount => Math.Clamp(request.TargetValue, 5, 40), StudyGoalTypes.Duration => Math.Clamp(request.TargetValue, 10, 60), StudyGoalTypes.WeakSystem => 10, StudyGoalTypes.DueReviews => Math.Clamp(request.TargetValue, 1, 20), _ => request.TargetValue }; if (request.GoalType == StudyGoalTypes.DueReviews) { var pending = checked((int)await _reviews.CountPendingAsync(userId, normalizedSystem)); if (pending == 0) throw new ArgumentException("No reviews are currently due."); target = Math.Min(target, pending); } var mastery = dashboard.Systems.FirstOrDefault(value => value.System == normalizedSystem); var session = new StudySession { UserId = userId, ClientSessionId = request.ClientSessionId, GoalType = request.GoalType, TargetValue = target, Mode = request.Mode, Step3System = normalizedSystem, State = StudySessionStates.Active, CreatedUtc = now, UpdatedUtc = now, TargetEndUtc = request.GoalType == StudyGoalTypes.Duration ? now.AddMinutes(target) : null, InitialMasteryScore = mastery?.MasteryScore, InitialMasteryState = mastery?.MasteryState ?? "", InitialReadiness = dashboard.ExamReadinessProjectedPassPercent }; await _sessions.InsertOneAsync(session); return session; } public async Task GetActiveAsync(string userId) { await CloseStaleAsync(userId, _clock.GetUtcNow().UtcDateTime); return await _sessions.Find(value => value.UserId == userId && value.State == StudySessionStates.Active) .SortByDescending(value => value.CreatedUtc).FirstOrDefaultAsync(); } public async Task GetOwnedAsync(string id, string userId) => await _sessions.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync(); public async Task RecordQuestionAsync( string id, string userId, QuestionAttempt attempt, Question question, IReadOnlyCollection awards) { var session = await GetOwnedAsync(id, userId); if (session is null || session.State != StudySessionStates.Active) return null; if (session.GoalType == StudyGoalTypes.DueReviews || session.GoalType == StudyGoalTypes.SimulationBlock) return null; if (session.GoalType == StudyGoalTypes.WeakSystem && Step3SystemCatalog.Normalize(question) != session.Step3System) return null; if (!attempt.IsSkip) await ApplyEventAsync(session, $"question:{attempt.Id}", 1, attempt.IsCorrect ? 1 : 0, 0, 0, awards); return await EvaluateAsync(id, userId); } public async Task RecordReviewAsync( string id, string userId, QuestionReviewAttempt attempt, bool recovered, IReadOnlyCollection awards) { var session = await GetOwnedAsync(id, userId); if (session is null || session.State != StudySessionStates.Active || session.GoalType != StudyGoalTypes.DueReviews) return null; await ApplyEventAsync(session, $"review:{attempt.Id}", 0, attempt.IsCorrect ? 1 : 0, 1, recovered ? 1 : 0, awards); return await EvaluateAsync(id, userId); } public async Task FinishEarlyAsync(string id, string userId) { var now = _clock.GetUtcNow().UtcDateTime; await _sessions.UpdateOneAsync(value => value.Id == id && value.UserId == userId && value.State == StudySessionStates.Active, Builders.Update.Set(value => value.State, StudySessionStates.EndedEarly) .Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now)); var session = await GetOwnedAsync(id, userId); if (session is null) return null; await SnapshotFinalAsync(session); return Envelope(session); } public Task CheckAsync(string id, string userId) => EvaluateAsync(id, userId); public async Task EnsureSimulationBlockAsync(string userId, string simulationId, int block) { var clientId = $"simulation:{simulationId}:{block}"; var existing = await _sessions.Find(value => value.UserId == userId && value.ClientSessionId == clientId).FirstOrDefaultAsync(); if (existing is not null) return existing; var now = _clock.GetUtcNow().UtcDateTime; await _sessions.UpdateManyAsync(value => value.UserId == userId && value.State == StudySessionStates.Active, Builders.Update.Set(value => value.State, StudySessionStates.Interrupted) .Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now)); var dashboard = await _dashboard.GetAsync(userId, now); var session = new StudySession { UserId = userId, ClientSessionId = clientId, GoalType = StudyGoalTypes.SimulationBlock, TargetValue = 1, Mode = "Simulation", State = StudySessionStates.Active, SimulationId = simulationId, SimulationBlock = block, CreatedUtc = now, UpdatedUtc = now, InitialReadiness = dashboard.ExamReadinessProjectedPassPercent }; try { await _sessions.InsertOneAsync(session); } catch (MongoWriteException) { return await _sessions.Find(value => value.UserId == userId && value.ClientSessionId == clientId).FirstAsync(); } return session; } public async Task CompleteSimulationBlockAsync(string userId, string simulationId, int block) { var session = await EnsureSimulationBlockAsync(userId, simulationId, block); if (session.State == StudySessionStates.Active) { var now = _clock.GetUtcNow().UtcDateTime; await _sessions.UpdateOneAsync(value => value.Id == session.Id && value.State == StudySessionStates.Active, Builders.Update.Set(value => value.QuestionsCompleted, 1) .Set(value => value.State, StudySessionStates.Completed).Set(value => value.CompletedUtc, now) .Set(value => value.UpdatedUtc, now)); session = (await GetOwnedAsync(session.Id!, userId))!; await ApplyCompletionBonusAsync(session); await SnapshotFinalAsync(session); } return Envelope((await GetOwnedAsync(session.Id!, userId))!); } public StudySessionEnvelope Envelope(StudySession session) => new() { Progress = Progress(session), Summary = session.State == StudySessionStates.Active ? null : Summary(session) }; private async Task ApplyEventAsync( StudySession session, string eventId, int questions, int correct, int reviews, int recovered, IReadOnlyCollection awards) { var now = _clock.GetUtcNow().UtcDateTime; var filter = Builders.Filter.Eq(value => value.Id, session.Id) & Builders.Filter.Eq(value => value.State, StudySessionStates.Active) & Builders.Filter.AnyNin(value => value.AppliedEventIds, [eventId]); var update = Builders.Update.AddToSet(value => value.AppliedEventIds, eventId) .Inc(value => value.QuestionsCompleted, questions).Inc(value => value.Correct, correct) .Inc(value => value.ReviewsCompleted, reviews).Inc(value => value.Recovered, recovered) .Inc(value => value.CoinsEarned, awards.Sum(value => value.Amount)).Set(value => value.UpdatedUtc, now); foreach (var award in awards) update = update.Push(value => value.CoinAwards, award); await _sessions.UpdateOneAsync(filter, update); } private async Task EvaluateAsync(string id, string userId) { var session = await GetOwnedAsync(id, userId); if (session is null) return null; if (session.State == StudySessionStates.Active && Reached(session, _clock.GetUtcNow().UtcDateTime)) { var now = _clock.GetUtcNow().UtcDateTime; var changed = await _sessions.UpdateOneAsync(value => value.Id == id && value.UserId == userId && value.State == StudySessionStates.Active, Builders.Update.Set(value => value.State, StudySessionStates.Completed) .Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now)); session = (await GetOwnedAsync(id, userId))!; if (changed.ModifiedCount == 1) { await ApplyCompletionBonusAsync(session); await SnapshotFinalAsync(session); session = (await GetOwnedAsync(id, userId))!; } } return Envelope(session); } private async Task ApplyCompletionBonusAsync(StudySession session) { if (!BonusEligible(session)) return; var result = await _credits.AwardAsync(session.UserId, new CreditAwardRequest( $"session:{session.Id}:complete", 3, CreditReasonCodes.SessionGoalComplete, "Completed a study session goal", "study_session", session.Id!, true)); if (result.Award is null) return; await _sessions.UpdateOneAsync(value => value.Id == session.Id, Builders.Update.Inc(value => value.CoinsEarned, result.Award.Amount) .Push(value => value.CoinAwards, result.Award)); } private async Task SnapshotFinalAsync(StudySession session) { var dashboard = await _dashboard.GetAsync(session.UserId, _clock.GetUtcNow().UtcDateTime); var mastery = dashboard.Systems.FirstOrDefault(value => value.System == session.Step3System); await _sessions.UpdateOneAsync(value => value.Id == session.Id, Builders.Update.Set(value => value.FinalMasteryScore, mastery == null ? null : mastery.MasteryScore) .Set(value => value.FinalMasteryState, mastery?.MasteryState ?? "") .Set(value => value.FinalReadiness, dashboard.ExamReadinessProjectedPassPercent)); } private async Task CloseStaleAsync(string userId, DateTime now) => await _sessions.UpdateManyAsync(value => value.UserId == userId && value.State == StudySessionStates.Active && value.UpdatedUtc < now.AddHours(-12), Builders.Update.Set(value => value.State, StudySessionStates.Interrupted) .Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now)); private static bool Reached(StudySession session, DateTime now) => session.GoalType switch { StudyGoalTypes.Duration => session.TargetEndUtc <= now && session.QuestionsCompleted > 0, StudyGoalTypes.DueReviews => session.ReviewsCompleted >= session.TargetValue, _ => session.QuestionsCompleted >= session.TargetValue }; private static bool BonusEligible(StudySession session) => session.GoalType switch { StudyGoalTypes.Duration => session.TargetValue >= 10 && session.QuestionsCompleted >= 5, StudyGoalTypes.DueReviews => session.ReviewsCompleted >= 5, StudyGoalTypes.SimulationBlock => true, _ => session.QuestionsCompleted >= 5 }; private StudySessionProgress Progress(StudySession session) { var completed = session.GoalType == StudyGoalTypes.DueReviews ? session.ReviewsCompleted : session.GoalType == StudyGoalTypes.Duration ? Math.Min(session.TargetValue * 60, (int)((session.CompletedUtc ?? _clock.GetUtcNow().UtcDateTime) - session.CreatedUtc).TotalSeconds) : session.QuestionsCompleted; return new StudySessionProgress { Id = session.Id ?? "", State = session.State, GoalType = session.GoalType, TargetValue = session.TargetValue, CompletedValue = completed, QuestionsCompleted = session.QuestionsCompleted, SecondsRemaining = session.TargetEndUtc.HasValue ? Math.Max(0, (int)(session.TargetEndUtc.Value - _clock.GetUtcNow().UtcDateTime).TotalSeconds) : 0, GoalReached = session.State == StudySessionStates.Completed }; } private StudySessionSummary Summary(StudySession session) { var progress = Progress(session); return new StudySessionSummary { Id = session.Id ?? "", State = session.State, GoalType = session.GoalType, TargetValue = session.TargetValue, CompletedValue = progress.CompletedValue, QuestionsCompleted = session.QuestionsCompleted, Correct = session.Correct, Accuracy = session.QuestionsCompleted == 0 ? 0 : (int)Math.Round(100.0 * session.Correct / session.QuestionsCompleted), ReviewsCompleted = session.ReviewsCompleted, Recovered = session.Recovered, ElapsedSeconds = Math.Max(0, (int)((session.CompletedUtc ?? _clock.GetUtcNow().UtcDateTime) - session.CreatedUtc).TotalSeconds), CoinsEarned = session.CoinsEarned, CoinAwards = session.CoinAwards, Step3System = session.Step3System, MasteryScoreChange = session.InitialMasteryScore.HasValue && session.FinalMasteryScore.HasValue ? session.FinalMasteryScore - session.InitialMasteryScore : null, InitialMasteryState = session.InitialMasteryState, FinalMasteryState = session.FinalMasteryState, ReadinessChange = session.InitialReadiness.HasValue && session.FinalReadiness.HasValue ? session.FinalReadiness - session.InitialReadiness : null, ReadinessExplanation = session.FinalReadiness != session.InitialReadiness ? "Readiness changed after a completed exam simulation." : "A completed exam simulation is required to update readiness.", GoalBonusAwarded = session.CoinAwards.Any(value => value.ReasonCode == CreditReasonCodes.SessionGoalComplete) }; } private static void Validate(CreateStudySessionRequest request) { if (!Guid.TryParse(request.ClientSessionId, out _)) throw new ArgumentException("clientSessionId must be a UUID."); if (!StudyGoalTypes.All.Contains(request.GoalType) || request.GoalType == StudyGoalTypes.SimulationBlock) throw new ArgumentException("Unsupported study goal type."); if (request.GoalType == StudyGoalTypes.WeakSystem && string.IsNullOrWhiteSpace(request.Step3System)) throw new ArgumentException("A Step 3 system is required for a weak-system goal."); if (request.Mode is not ("Tutor" or "Timed")) throw new ArgumentException("mode must be Tutor or Timed."); } }