137 lines
6.7 KiB
C#
137 lines
6.7 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using QuestionsApi.Models;
|
|
using QuestionsApi.Services;
|
|
using System.Security.Claims;
|
|
|
|
namespace QuestionsApi.Controllers;
|
|
|
|
[ApiController]
|
|
[Route("api/review-queue")]
|
|
[Authorize(Roles = "USER,SUPER")]
|
|
public sealed class ReviewQueueController(
|
|
ReviewQueueStore queue,
|
|
QuestionStore questions,
|
|
AnswerStatisticsStore statistics,
|
|
RatingStore ratings,
|
|
QuestionBookmarkStore bookmarks,
|
|
CreditStore credits,
|
|
MasteryAwardService masteryAwards,
|
|
StudySessionStore studySessions,
|
|
DailyMissionStore dailyMissions,
|
|
ProgressionStore progression) : ControllerBase
|
|
{
|
|
[HttpPost("next")]
|
|
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
await queue.EnsureUserQueueAsync(userId);
|
|
ReviewQueueItem? item;
|
|
try { item = await queue.GetRandomPendingAsync(userId, req.ExcludeExternalIds, req.Step3System); }
|
|
catch (ArgumentException exception) { return BadRequest(exception.Message); }
|
|
if (item is null)
|
|
return Ok(new ReviewQueueNextResponse { PendingCount = checked((int)await queue.CountPendingAsync(userId, req.Step3System)) });
|
|
|
|
var question = await questions.GetByExternalIdAsync(item.QuestionExternalId);
|
|
if (question is null || !question.IsPublished) return NotFound();
|
|
var percentages = await statistics.GetPercentagesForQuestionAsync(question.ExternalId);
|
|
var response = QuestionResponse.FromModel(
|
|
question,
|
|
percentages,
|
|
await ratings.GetQuestionEloAsync(question.ExternalId),
|
|
req.SupportsChoicePools);
|
|
response.IsBookmarked = await bookmarks.IsBookmarkedAsync(userId, question.ExternalId);
|
|
return Ok(new ReviewQueueNextResponse
|
|
{
|
|
Question = response,
|
|
Cycle = item.Cycle,
|
|
PendingCount = checked((int)await queue.CountPendingAsync(userId, req.Step3System))
|
|
});
|
|
}
|
|
|
|
[HttpPost("attempts")]
|
|
public async Task<IActionResult> RecordAttempt([FromBody] ReviewQueueAttemptRequest req)
|
|
{
|
|
var userId = UserId();
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
if (!Guid.TryParse(req.ClientAttemptId, out _)) return BadRequest("clientAttemptId must be a UUID");
|
|
if (string.IsNullOrWhiteSpace(req.QuestionExternalId)) return BadRequest("questionExternalId required");
|
|
if (req.Cycle < 1) return BadRequest("cycle must be positive");
|
|
if (string.IsNullOrWhiteSpace(req.ChoiceId)) return BadRequest("choiceId required");
|
|
|
|
var question = await questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
|
|
if (question is null || !question.IsPublished) return NotFound();
|
|
var choiceId = req.ChoiceId.Trim();
|
|
if (!question.Choices.Any(choice => choice.Id == choiceId)) return BadRequest("choiceId must match a choice id");
|
|
var presented = req.PresentedChoiceIds.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value.Trim()).ToList();
|
|
if (presented.Count > 0)
|
|
{
|
|
var validIds = question.Choices.Select(choice => choice.Id).ToHashSet(StringComparer.Ordinal);
|
|
if (presented.Count != 5 || presented.Distinct(StringComparer.Ordinal).Count() != 5)
|
|
return BadRequest("presentedChoiceIds must contain exactly five unique choice ids");
|
|
if (presented.Any(value => !validIds.Contains(value))
|
|
|| !presented.Contains(choiceId, StringComparer.Ordinal)
|
|
|| !presented.Contains(question.CorrectChoiceId, StringComparer.Ordinal))
|
|
return BadRequest("presentedChoiceIds must include valid selected and correct choices");
|
|
}
|
|
|
|
await masteryAwards.PrepareAsync(userId);
|
|
await progression.PrepareAsync(userId);
|
|
var result = await queue.RecordReviewAsync(
|
|
userId,
|
|
req.ClientAttemptId,
|
|
question.ExternalId,
|
|
req.Cycle,
|
|
choiceId,
|
|
presented,
|
|
choiceId == question.CorrectChoiceId,
|
|
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)
|
|
{
|
|
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),
|
|
delayed ? CreditReasonCodes.DelayedRecovery : CreditReasonCodes.ScheduledReview,
|
|
delayed ? "Delayed recovery of a missed question" : "Correct scheduled review",
|
|
"question_review", result.Attempt.Id));
|
|
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)));
|
|
var missionResult = await dailyMissions.RecordReviewAsync(userId, result.Attempt);
|
|
if (missionResult is not null) awards.AddRange(missionResult.CreditAwards);
|
|
var progressionResult = await progression.RefreshAsync(userId);
|
|
awards.AddRange(progressionResult.Awards);
|
|
return Ok(new ReviewQueueAttemptResponse
|
|
{
|
|
QuestionExternalId = question.ExternalId,
|
|
IsCorrect = result.Attempt.IsCorrect,
|
|
QueueState = result.Item?.State ?? ReviewQueueStates.Pending,
|
|
PendingCount = checked((int)await queue.CountPendingAsync(userId)),
|
|
Credits = await credits.GetStatusAsync(userId),
|
|
CreditAwards = awards,
|
|
StudySessionProgress = studySession?.Progress,
|
|
SessionSummary = studySession?.Summary,
|
|
DailyMissions = missionResult?.State,
|
|
Progression = progressionResult.State
|
|
});
|
|
}
|
|
|
|
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
?? User.FindFirstValue(ClaimTypes.Name)
|
|
?? User.Identity?.Name
|
|
?? "";
|
|
}
|