using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; using QuestionsApi.Models; using QuestionsApi.Services; using System.Security.Claims; namespace QuestionsApi.Controllers; [ApiController] [Route("api/[controller]")] public class QuestionsController : ControllerBase { private readonly QuestionStore _questions; private readonly QuestionAttemptStore _attempts; private readonly RatingStore _ratings; private readonly QuestionMediaStore _media; private readonly AnswerStatisticsStore _statistics; private readonly ServiceMetrics _metrics; private readonly QuestionBookmarkStore _bookmarks; private readonly ReviewQueueStore _reviewQueue; private readonly CreditStore _credits; public QuestionsController( QuestionStore questions, QuestionAttemptStore attempts, RatingStore ratings, QuestionMediaStore media, AnswerStatisticsStore statistics, ServiceMetrics metrics, QuestionBookmarkStore bookmarks, ReviewQueueStore reviewQueue, CreditStore credits) { _questions = questions; _attempts = attempts; _ratings = ratings; _media = media; _statistics = statistics; _metrics = metrics; _bookmarks = bookmarks; _reviewQueue = reviewQueue; _credits = credits; } [HttpGet("systems")] [Authorize(Roles = "USER,SUPER")] public async Task Systems() { var systems = await _questions.GetSystemsAsync(User.IsInRole("SUPER")); return Ok(systems); } [HttpPost("search")] [Authorize(Roles = "USER,SUPER")] public async Task Search([FromBody] QuestionSearchRequest req) { var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER")); var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId)); var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId)); var bookmarkedIds = await _bookmarks.GetBookmarkedIdsAsync(UserId(), questions.Select(q => q.ExternalId)); return Ok(questions.Select(q => { percentages.TryGetValue(q.ExternalId, out var questionPercentages); var response = QuestionResponse.FromModel( q, questionPercentages, questionElos.GetValueOrDefault(q.ExternalId, 1000), req.SupportsChoicePools); response.IsBookmarked = bookmarkedIds.Contains(q.ExternalId); return response; }).ToList()); } [HttpGet("admin/export")] [Authorize(Roles = "SUPER")] public async Task AdminExport([FromQuery] string? after = null, [FromQuery] int limit = 100) { try { var page = await _questions.ExportPageAsync(after, limit); return Ok(new QuestionAdminExportResponse { Items = page.Items.Select(question => QuestionResponse.FromModel(question)).ToList(), NextCursor = page.NextCursor, HasMore = page.HasMore }); } catch (ArgumentException exception) { return BadRequest(exception.Message); } } [HttpGet("{id}")] [Authorize(Roles = "USER,SUPER")] public async Task Get(string id) { var question = await _questions.GetByIdAsync(id); if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER"))) return NotFound(); var percentages = await _statistics.GetPercentagesForQuestionAsync(question.ExternalId); var questionElo = await _ratings.GetQuestionEloAsync(question.ExternalId); var response = QuestionResponse.FromModel(question, percentages, questionElo); response.IsBookmarked = await _bookmarks.IsBookmarkedAsync(UserId(), question.ExternalId); return Ok(response); } private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? ""; [HttpPost("attempts/first")] [Authorize(Roles = "USER,SUPER")] public async Task RecordFirstAttempt([FromBody] FirstQuestionAttemptRequest req) { if (string.IsNullOrWhiteSpace(req.QuestionExternalId)) return BadRequest("questionExternalId required"); if (string.IsNullOrWhiteSpace(req.ChoiceId)) return BadRequest("choiceId required"); var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim()); if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER"))) return NotFound(); if (!question.Choices.Any(c => c.Id == req.ChoiceId.Trim())) return BadRequest("choiceId must match a choice id"); var presentedChoiceIds = req.PresentedChoiceIds .Where(id => !string.IsNullOrWhiteSpace(id)) .Select(id => id.Trim()) .ToList(); if (presentedChoiceIds.Count > 0) { var validChoiceIds = question.Choices.Select(choice => choice.Id).ToHashSet(StringComparer.Ordinal); if (presentedChoiceIds.Count != 5 || presentedChoiceIds.Distinct(StringComparer.Ordinal).Count() != 5) return BadRequest("presentedChoiceIds must contain exactly five unique choice ids"); if (presentedChoiceIds.Any(id => !validChoiceIds.Contains(id))) return BadRequest("presentedChoiceIds must match question choice ids"); if (!presentedChoiceIds.Contains(req.ChoiceId.Trim(), StringComparer.Ordinal) || !presentedChoiceIds.Contains(question.CorrectChoiceId, StringComparer.Ordinal)) return BadRequest("presentedChoiceIds must include the selected and correct choices"); } var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? ""; if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var record = await _attempts.RecordFirstAttemptAsync( question.ExternalId, userId, req.ChoiceId.Trim(), req.ChoiceId.Trim() == question.CorrectChoiceId, false, presentedChoiceIds, req.DurationSeconds); if (record.Inserted) { _metrics.AttemptCreated(); try { await _statistics.IncrementAsync(record.Attempt); } catch { _metrics.CounterFailure(); } } else { _metrics.AttemptDuplicate(); } await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); if (record.Inserted && !User.IsInRole("SUPER")) await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? ""); var ratingOwner = Guid.NewGuid().ToString("N"); var shouldRate = await _attempts.TryAcquireRatingLeaseAsync( record.Attempt.Id ?? "", ratingOwner, TimeSpan.FromSeconds(30)); RatingChangeResponse rating; try { rating = await _ratings.ApplyFirstResultAsync( userId, question, record.Attempt.IsCorrect, shouldRate); if (shouldRate) await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo); } catch { _metrics.RatingFailure(); if (shouldRate) await _attempts.ReleaseRatingLeaseAsync(record.Attempt.Id ?? "", ratingOwner); throw; } return Ok(new FirstQuestionAttemptResponse { QuestionExternalId = question.ExternalId, ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages( question, await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)), QuestionElo = rating.QuestionElo, Rating = rating, Credits = await _credits.GetStatusAsync(userId) }); } [HttpPost("media")] [Authorize(Roles = "SUPER")] [RequestSizeLimit(5_000_000)] public async Task UploadMedia(IFormFile file) { if (file is null || file.Length == 0) return BadRequest("file required"); if (file.Length > 5_000_000) return BadRequest("file must be 5 MB or smaller"); var contentType = file.ContentType.Trim().ToLowerInvariant(); var allowedTypes = new HashSet { "image/png", "image/jpeg", "image/webp" }; if (!allowedTypes.Contains(contentType)) return BadRequest("file must be a png, jpeg, or webp image"); await using var stream = file.OpenReadStream(); using var memory = new MemoryStream(); await stream.CopyToAsync(memory); var asset = await _media.CreateAsync(file.FileName, contentType, memory.ToArray()); return Ok(new { id = asset.Id ?? "", url = $"/api/questions/media/{asset.Id}" }); } [HttpGet("media/{id}")] public async Task GetMedia(string id) { var asset = await _media.GetAsync(id.Trim()); if (asset is null) return NotFound(); return File(asset.Bytes, asset.ContentType); } [HttpPost("{externalId}/skip")] [Authorize(Roles = "USER,SUPER")] public async Task Skip(string externalId) { if (string.IsNullOrWhiteSpace(externalId)) return BadRequest("externalId required"); var question = await _questions.GetByExternalIdAsync(externalId.Trim()); if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER"))) return NotFound(); var userId = CurrentUserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var record = await _attempts.RecordFirstAttemptAsync( question.ExternalId, userId, "", false, true); if (record.Inserted) _metrics.AttemptCreated(); else _metrics.AttemptDuplicate(); var ratingOwner = Guid.NewGuid().ToString("N"); var shouldRate = await _attempts.TryAcquireRatingLeaseAsync( record.Attempt.Id ?? "", ratingOwner, TimeSpan.FromSeconds(30)); RatingChangeResponse rating; try { rating = await _ratings.ApplyFirstResultAsync(userId, question, false, shouldRate); if (shouldRate) await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo); } catch { _metrics.RatingFailure(); if (shouldRate) await _attempts.ReleaseRatingLeaseAsync(record.Attempt.Id ?? "", ratingOwner); throw; } return Ok(new SkipQuestionResponse { QuestionExternalId = question.ExternalId, CorrectChoiceId = question.CorrectChoiceId, ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages( question, await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)), QuestionElo = rating.QuestionElo, Rating = rating }); } [HttpPost] [Authorize(Roles = "SUPER")] public async Task Create([FromBody] UpsertQuestionRequest req) { var validation = QuestionValidator.Validate(req); if (validation is not null) return BadRequest(validation); var created = await _questions.CreateAsync(req); if (created is null) return Conflict("Question externalId already exists"); return Ok(QuestionResponse.FromModel(created)); } [HttpPost("bulk")] [Authorize(Roles = "SUPER")] public async Task BulkUpsert([FromBody] BulkUpsertQuestionsRequest req) { if (req.Questions.Count == 0) return BadRequest("questions required"); foreach (var question in req.Questions) { var validation = QuestionValidator.Validate(question); if (validation is not null) return BadRequest($"{question.ExternalId}: {validation}"); } var upserted = await _questions.UpsertManyByExternalIdAsync(req.Questions); return Ok(new { upserted }); } [HttpPost("admin/references/bulk")] [Authorize(Roles = "SUPER")] public async Task BulkUpdateReferences([FromBody] BulkQuestionReferencesRequest req) { if (req.Questions.Count == 0) return BadRequest("questions required"); var externalIds = new HashSet(StringComparer.Ordinal); foreach (var update in req.Questions) { if (string.IsNullOrWhiteSpace(update.ExternalId)) return BadRequest("externalId required"); var externalId = update.ExternalId.Trim(); if (!externalIds.Add(externalId)) return BadRequest($"duplicate externalId: {externalId}"); var validation = QuestionValidator.ValidateReferences(update.References, required: true); if (validation is not null) return BadRequest($"{externalId}: {validation}"); } var missing = await _questions.GetMissingExternalIdsAsync(externalIds); if (missing.Count > 0) return BadRequest($"unknown externalIds: {string.Join(", ", missing)}"); var updated = await _questions.UpdateReferencesByExternalIdAsync(req.Questions); if (updated != req.Questions.Count) return Conflict("one or more questions changed before references could be updated"); return Ok(new { updated }); } [HttpPut("{id}")] [Authorize(Roles = "SUPER")] public async Task Update(string id, [FromBody] UpsertQuestionRequest req) { var validation = QuestionValidator.Validate(req); if (validation is not null) return BadRequest(validation); try { var updated = await _questions.UpdateAsync(id, req); if (updated is null) return NotFound(); return Ok(QuestionResponse.FromModel(updated)); } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { return Conflict("Question externalId already exists"); } } [HttpDelete("{id}")] [Authorize(Roles = "SUPER")] public async Task Delete(string id) { var deleted = await _questions.DeleteAsync(id); if (!deleted) return NotFound(); return Ok("Deleted"); } [HttpPost("{id}/flag")] [Authorize(Roles = "SUPER")] public async Task Flag(string id, [FromBody] FlagQuestionRequest req) { if (string.IsNullOrWhiteSpace(req.Reason)) return BadRequest("reason required"); var flagged = await _questions.FlagAsync(id, req.Reason.Trim(), CurrentUserId()); if (flagged is null) return NotFound(); return Ok(QuestionFlagResponse.FromModel(flagged)); } [HttpPost("{id}/unflag")] [Authorize(Roles = "SUPER")] public async Task Unflag(string id) { var unflagged = await _questions.UnflagAsync(id); if (unflagged is null) return NotFound(); return Ok(QuestionFlagResponse.FromModel(unflagged)); } [HttpGet("admin/flagged")] [Authorize(Roles = "SUPER")] public async Task Flagged() { var flagged = await _questions.GetFlaggedAsync(); return Ok(flagged.Select(QuestionFlagResponse.FromModel)); } private string CurrentUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? ""; }