Add study session goals and summaries

This commit is contained in:
hz
2026-08-01 02:34:58 -05:00
parent ddc36a8048
commit 2bd2628009
27 changed files with 926 additions and 21 deletions
@@ -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();
}