308 lines
17 KiB
C#
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.");
|
|
}
|
|
}
|