Add study session goals and summaries
This commit is contained in:
@@ -12,7 +12,8 @@ namespace QuestionsApi.Controllers;
|
||||
public class ExamSimulationsController(
|
||||
ExamSimulationStore simulations,
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards) : ControllerBase
|
||||
MasteryAwardService masteryAwards,
|
||||
StudySessionStore studySessions) : ControllerBase
|
||||
{
|
||||
[HttpGet("readiness")]
|
||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||
@@ -116,7 +117,12 @@ public class ExamSimulationsController(
|
||||
{
|
||||
var simulation = await simulations.GetOwnedAsync(id, UserId());
|
||||
if (simulation is null) return NotFound();
|
||||
try { return Ok(await simulations.ToResponseAsync(await simulations.StartNextBlockAsync(simulation))); }
|
||||
try
|
||||
{
|
||||
simulation = await simulations.StartNextBlockAsync(simulation);
|
||||
await studySessions.EnsureSimulationBlockAsync(UserId(), simulation.Id ?? id, simulation.CurrentBlock);
|
||||
return Ok(await simulations.ToResponseAsync(simulation));
|
||||
}
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
}
|
||||
|
||||
@@ -141,6 +147,12 @@ public class ExamSimulationsController(
|
||||
await masteryAwards.PrepareAsync(userId);
|
||||
simulation = await simulations.SubmitBlockAsync(simulation);
|
||||
var response = await simulations.ToResponseAsync(simulation);
|
||||
var session = await studySessions.CompleteSimulationBlockAsync(userId, simulation.Id ?? id, simulation.CurrentBlock);
|
||||
response.StudySessionProgress = session.Progress;
|
||||
response.SessionSummary = session.Summary;
|
||||
if (session.Summary is not null)
|
||||
response.CreditAwards.AddRange(session.Summary.CoinAwards.Where(value =>
|
||||
value.ReasonCode == CreditReasonCodes.SessionGoalComplete));
|
||||
if (simulation.State == ExamSimulationStates.Completed && !simulation.IsSample)
|
||||
{
|
||||
var result = await credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
@@ -150,6 +162,8 @@ public class ExamSimulationsController(
|
||||
response.CreditAwards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
|
||||
response.Credits = await credits.GetStatusAsync(userId);
|
||||
}
|
||||
else if (session.Summary is not null)
|
||||
response.Credits = await credits.GetStatusAsync(userId);
|
||||
return Ok(response);
|
||||
}
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
|
||||
@@ -21,6 +21,7 @@ public class QuestionsController : ControllerBase
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly MasteryAwardService _masteryAwards;
|
||||
private readonly StudySessionStore _studySessions;
|
||||
|
||||
public QuestionsController(
|
||||
QuestionStore questions,
|
||||
@@ -32,7 +33,8 @@ public class QuestionsController : ControllerBase
|
||||
QuestionBookmarkStore bookmarks,
|
||||
ReviewQueueStore reviewQueue,
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards)
|
||||
MasteryAwardService masteryAwards,
|
||||
StudySessionStore studySessions)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -44,6 +46,7 @@ public class QuestionsController : ControllerBase
|
||||
_reviewQueue = reviewQueue;
|
||||
_credits = credits;
|
||||
_masteryAwards = masteryAwards;
|
||||
_studySessions = studySessions;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -221,6 +224,14 @@ public class QuestionsController : ControllerBase
|
||||
}
|
||||
|
||||
creditAwards.AddRange(await _masteryAwards.AwardPromotionsAsync(userId));
|
||||
StudySessionEnvelope? studySession = null;
|
||||
if (!string.IsNullOrWhiteSpace(req.StudySessionId))
|
||||
studySession = await _studySessions.RecordQuestionAsync(
|
||||
req.StudySessionId, userId, record.Attempt, question, creditAwards);
|
||||
if (studySession?.Summary is not null)
|
||||
creditAwards.AddRange(studySession.Summary.CoinAwards.Where(value =>
|
||||
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
|
||||
&& creditAwards.All(existing => existing.ReasonCode != value.ReasonCode)));
|
||||
return Ok(new FirstQuestionAttemptResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
@@ -230,7 +241,9 @@ public class QuestionsController : ControllerBase
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating,
|
||||
Credits = await _credits.GetStatusAsync(userId),
|
||||
CreditAwards = creditAwards
|
||||
CreditAwards = creditAwards,
|
||||
StudySessionProgress = studySession?.Progress,
|
||||
SessionSummary = studySession?.Summary
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ public sealed class ReviewQueueController(
|
||||
RatingStore ratings,
|
||||
QuestionBookmarkStore bookmarks,
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards) : ControllerBase
|
||||
MasteryAwardService masteryAwards,
|
||||
StudySessionStore studySessions) : ControllerBase
|
||||
{
|
||||
[HttpPost("next")]
|
||||
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
|
||||
@@ -85,9 +86,10 @@ public sealed class ReviewQueueController(
|
||||
req.DurationSeconds);
|
||||
if (result is null) return Conflict("review cycle is no longer active");
|
||||
var awards = new List<CreditAward>();
|
||||
var delayed = false;
|
||||
if (result.Attempt.IsCorrect)
|
||||
{
|
||||
var delayed = result.Item is not null
|
||||
delayed = result.Item is not null
|
||||
&& result.Attempt.CreatedUtc - result.Item.CreatedUtc >= TimeSpan.FromHours(24);
|
||||
var award = await credits.AwardAsync(userId, new CreditAwardRequest(
|
||||
$"review:{result.Attempt.Id}", CreditEconomy.ReviewAmount(delayed),
|
||||
@@ -97,6 +99,14 @@ public sealed class ReviewQueueController(
|
||||
if (award.Award is not null) awards.Add(award.Award);
|
||||
}
|
||||
awards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
|
||||
StudySessionEnvelope? studySession = null;
|
||||
if (!string.IsNullOrWhiteSpace(req.StudySessionId))
|
||||
studySession = await studySessions.RecordReviewAsync(
|
||||
req.StudySessionId, userId, result.Attempt, delayed, awards);
|
||||
if (studySession?.Summary is not null)
|
||||
awards.AddRange(studySession.Summary.CoinAwards.Where(value =>
|
||||
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
|
||||
&& awards.All(existing => existing.ReasonCode != value.ReasonCode)));
|
||||
return Ok(new ReviewQueueAttemptResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
@@ -104,7 +114,9 @@ public sealed class ReviewQueueController(
|
||||
QueueState = result.Item?.State ?? ReviewQueueStates.Pending,
|
||||
PendingCount = checked((int)await queue.CountPendingAsync(userId)),
|
||||
Credits = await credits.GetStatusAsync(userId),
|
||||
CreditAwards = awards
|
||||
CreditAwards = awards,
|
||||
StudySessionProgress = studySession?.Progress,
|
||||
SessionSummary = studySession?.Summary
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/study-sessions")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class StudySessionsController(StudySessionStore sessions) : ControllerBase
|
||||
{
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateStudySessionRequest request)
|
||||
{
|
||||
try { return Ok(sessions.Envelope(await sessions.CreateAsync(UserId(), request))); }
|
||||
catch (ArgumentException exception) { return BadRequest(exception.Message); }
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
}
|
||||
|
||||
[HttpGet("active")]
|
||||
public async Task<IActionResult> Active()
|
||||
{
|
||||
var session = await sessions.GetActiveAsync(UserId());
|
||||
return session is null ? NoContent() : Ok(sessions.Envelope(session));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/finish")]
|
||||
public async Task<IActionResult> Finish(string id)
|
||||
{
|
||||
var result = await sessions.FinishEarlyAsync(id, UserId());
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id}/summary")]
|
||||
public async Task<IActionResult> Summary(string id)
|
||||
{
|
||||
var session = await sessions.GetOwnedAsync(id, UserId());
|
||||
return session is null ? NotFound() : Ok(sessions.Envelope(session));
|
||||
}
|
||||
|
||||
[HttpPost("{id}/check")]
|
||||
public async Task<IActionResult> Check(string id)
|
||||
{
|
||||
var result = await sessions.CheckAsync(id, UserId());
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -109,6 +109,7 @@ public static class CreditReasonCodes
|
||||
public const string MasteryStrong = "mastery_strong";
|
||||
public const string MasteryMastered = "mastery_mastered";
|
||||
public const string ExamSimulationComplete = "exam_simulation_complete";
|
||||
public const string SessionGoalComplete = "session_goal_complete";
|
||||
public const string Purchase = "purchase";
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ public class ExamSimulationStateResponse
|
||||
public List<QuestionResponse> ReviewQuestions { get; set; } = [];
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||
public StudySessionSummary? SessionSummary { get; set; }
|
||||
}
|
||||
|
||||
public class ExamSimulationReadinessResponse
|
||||
|
||||
@@ -51,4 +51,5 @@ public class FirstQuestionAttemptRequest
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public int DurationSeconds { get; set; }
|
||||
public bool MetTimeLimit { get; set; } = true;
|
||||
public string? StudySessionId { get; set; }
|
||||
}
|
||||
|
||||
@@ -147,6 +147,8 @@ public class FirstQuestionAttemptResponse
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||
public StudySessionSummary? SessionSummary { get; set; }
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
|
||||
@@ -73,6 +73,7 @@ public sealed class ReviewQueueAttemptRequest
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public int DurationSeconds { get; set; }
|
||||
public string? StudySessionId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueAttemptResponse
|
||||
@@ -83,6 +84,8 @@ public sealed class ReviewQueueAttemptResponse
|
||||
public int PendingCount { get; set; }
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||
public StudySessionSummary? SessionSummary { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ReviewQueueRecordResult
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class StudyGoalTypes
|
||||
{
|
||||
public const string QuestionCount = "question_count";
|
||||
public const string Duration = "duration";
|
||||
public const string WeakSystem = "weak_system";
|
||||
public const string DueReviews = "due_reviews";
|
||||
public const string SimulationBlock = "simulation_block";
|
||||
public static readonly HashSet<string> All = [QuestionCount, Duration, WeakSystem, DueReviews, SimulationBlock];
|
||||
}
|
||||
|
||||
public static class StudySessionStates
|
||||
{
|
||||
public const string Active = "active";
|
||||
public const string Completed = "completed";
|
||||
public const string EndedEarly = "ended_early";
|
||||
public const string Interrupted = "interrupted";
|
||||
}
|
||||
|
||||
public sealed class StudySession
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public string ClientSessionId { get; set; } = "";
|
||||
public string GoalType { get; set; } = StudyGoalTypes.QuestionCount;
|
||||
public string Mode { get; set; } = "Tutor";
|
||||
public string Step3System { get; set; } = "";
|
||||
public int TargetValue { get; set; } = 10;
|
||||
public string State { get; set; } = StudySessionStates.Active;
|
||||
public int QuestionsCompleted { get; set; }
|
||||
public int Correct { get; set; }
|
||||
public int ReviewsCompleted { get; set; }
|
||||
public int Recovered { get; set; }
|
||||
public int CoinsEarned { get; set; }
|
||||
public List<CreditAward> CoinAwards { get; set; } = [];
|
||||
public List<string> AppliedEventIds { get; set; } = [];
|
||||
public int? InitialMasteryScore { get; set; }
|
||||
public string InitialMasteryState { get; set; } = "";
|
||||
public int? FinalMasteryScore { get; set; }
|
||||
public string FinalMasteryState { get; set; } = "";
|
||||
public int? InitialReadiness { get; set; }
|
||||
public int? FinalReadiness { get; set; }
|
||||
public string SimulationId { get; set; } = "";
|
||||
public int? SimulationBlock { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; }
|
||||
public DateTime? TargetEndUtc { get; set; }
|
||||
public DateTime? CompletedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class CreateStudySessionRequest
|
||||
{
|
||||
public string ClientSessionId { get; set; } = "";
|
||||
public string GoalType { get; set; } = StudyGoalTypes.QuestionCount;
|
||||
public int TargetValue { get; set; } = 10;
|
||||
public string Mode { get; set; } = "Tutor";
|
||||
public string Step3System { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class StudySessionProgress
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string State { get; set; } = "";
|
||||
public string GoalType { get; set; } = "";
|
||||
public int TargetValue { get; set; }
|
||||
public int CompletedValue { get; set; }
|
||||
public int QuestionsCompleted { get; set; }
|
||||
public int SecondsRemaining { get; set; }
|
||||
public bool GoalReached { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StudySessionSummary
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string State { get; set; } = "";
|
||||
public string GoalType { get; set; } = "";
|
||||
public int TargetValue { get; set; }
|
||||
public int CompletedValue { get; set; }
|
||||
public int QuestionsCompleted { get; set; }
|
||||
public int Correct { get; set; }
|
||||
public int Accuracy { get; set; }
|
||||
public int ReviewsCompleted { get; set; }
|
||||
public int Recovered { get; set; }
|
||||
public int ElapsedSeconds { get; set; }
|
||||
public int CoinsEarned { get; set; }
|
||||
public List<CreditAward> CoinAwards { get; set; } = [];
|
||||
public string Step3System { get; set; } = "";
|
||||
public int? MasteryScoreChange { get; set; }
|
||||
public string InitialMasteryState { get; set; } = "";
|
||||
public string FinalMasteryState { get; set; } = "";
|
||||
public int? ReadinessChange { get; set; }
|
||||
public string ReadinessExplanation { get; set; } = "A completed exam simulation is required to update readiness.";
|
||||
public bool GoalBonusAwarded { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StudySessionEnvelope
|
||||
{
|
||||
public StudySessionProgress Progress { get; set; } = new();
|
||||
public StudySessionSummary? Summary { get; set; }
|
||||
}
|
||||
@@ -111,6 +111,7 @@ builder.Services.AddSingleton<UserNotepadStore>();
|
||||
builder.Services.AddSingleton<QuestionBookmarkStore>();
|
||||
builder.Services.AddSingleton<ReviewQueueStore>();
|
||||
builder.Services.AddSingleton<DashboardStore>();
|
||||
builder.Services.AddSingleton<StudySessionStore>();
|
||||
builder.Services.AddSingleton<UserProfileStore>();
|
||||
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
|
||||
builder.Services.AddSingleton<FlashcardDeckStore>();
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserCreditAccounts", "CreditLedgerEntries"
|
||||
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
Reference in New Issue
Block a user