Files
step3/api/QuestionsApi/Services/StudySessionStore.cs
T

308 lines
17 KiB
C#

using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class StudySessionStore
{
private readonly IMongoCollection<StudySession> _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<StudySession>("StudySessions");
_dashboard = dashboard;
_credits = credits;
_reviews = reviews;
_clock = clock;
_sessions.Indexes.CreateOne(new CreateIndexModel<StudySession>(
Builders<StudySession>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ClientSessionId),
new CreateIndexOptions { Unique = true, Name = "uq_study_session_client" }));
_sessions.Indexes.CreateOne(new CreateIndexModel<StudySession>(
Builders<StudySession>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State),
new CreateIndexOptions { Name = "study_session_user_state" }));
}
public async Task<StudySession> 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<StudySession>.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<StudySession?> 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<StudySession?> GetOwnedAsync(string id, string userId) =>
await _sessions.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
public async Task<StudySessionEnvelope?> RecordQuestionAsync(
string id, string userId, QuestionAttempt attempt, Question question, IReadOnlyCollection<CreditAward> 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<StudySessionEnvelope?> RecordReviewAsync(
string id, string userId, QuestionReviewAttempt attempt, bool recovered, IReadOnlyCollection<CreditAward> 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<StudySessionEnvelope?> 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<StudySession>.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<StudySessionEnvelope?> CheckAsync(string id, string userId) => EvaluateAsync(id, userId);
public async Task<StudySession> 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<StudySession>.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<StudySessionEnvelope> 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<StudySession>.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<CreditAward> awards)
{
var now = _clock.GetUtcNow().UtcDateTime;
var filter = Builders<StudySession>.Filter.Eq(value => value.Id, session.Id)
& Builders<StudySession>.Filter.Eq(value => value.State, StudySessionStates.Active)
& Builders<StudySession>.Filter.AnyNin(value => value.AppliedEventIds, [eventId]);
var update = Builders<StudySession>.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<StudySessionEnvelope?> 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<StudySession>.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<StudySession>.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<StudySession>.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<StudySession>.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.");
}
}