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) : ControllerBase { [HttpPost("next")] public async Task 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 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); 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(); 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); 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 }); } private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? ""; }