Files
step3/api/QuestionsApi/Controllers/ReviewQueueController.cs
T
2026-07-19 12:51:24 -05:00

96 lines
4.3 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) : ControllerBase
{
[HttpPost("next")]
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
await queue.EnsureUserQueueAsync(userId);
var item = await queue.GetRandomPendingAsync(userId, req.ExcludeExternalIds);
if (item is null)
return Ok(new ReviewQueueNextResponse { PendingCount = checked((int)await queue.CountPendingAsync(userId)) });
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))
});
}
[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");
}
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");
return Ok(new ReviewQueueAttemptResponse
{
QuestionExternalId = question.ExternalId,
IsCorrect = result.Attempt.IsCorrect,
QueueState = result.Item?.State ?? ReviewQueueStates.Pending,
PendingCount = checked((int)await queue.CountPendingAsync(userId))
});
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
}