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; public QuestionsController( QuestionStore questions, QuestionAttemptStore attempts, RatingStore ratings, QuestionMediaStore media) { _questions = questions; _attempts = attempts; _ratings = ratings; _media = media; } [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 _attempts.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId)); var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId)); return Ok(questions.Select(q => { percentages.TryGetValue(q.ExternalId, out var questionPercentages); return QuestionResponse.FromModel(q, questionPercentages, questionElos.GetValueOrDefault(q.ExternalId, 1000)); }).ToList()); } [HttpGet("{id}")] [Authorize(Roles = "USER,SUPER")] public async Task Get(string id) { var question = await _questions.GetByIdAsync(id); if (question is null || (!question.IsPublished && !User.IsInRole("SUPER"))) return NotFound(); var percentages = await _attempts.GetPercentagesForQuestionAsync(question.ExternalId); var questionElo = await _ratings.GetQuestionEloAsync(question.ExternalId); return Ok(QuestionResponse.FromModel(question, percentages, questionElo)); } [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 || (!question.IsPublished && !User.IsInRole("SUPER"))) return NotFound(); if (!question.Choices.Any(c => c.Id == req.ChoiceId.Trim())) return BadRequest("choiceId must match a choice id"); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? ""; if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var wasFirstAttempt = await _attempts.RecordFirstAttemptAsync( question.ExternalId, userId, req.ChoiceId.Trim(), req.ChoiceId.Trim() == question.CorrectChoiceId); var rating = await _ratings.ApplyFirstResultAsync( userId, question, req.ChoiceId.Trim() == question.CorrectChoiceId, wasFirstAttempt); return Ok(new FirstQuestionAttemptResponse { QuestionExternalId = question.ExternalId, ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages( question, await _attempts.GetPercentagesForQuestionAsync(question.ExternalId)), QuestionElo = rating.QuestionElo, Rating = rating }); } [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 || (!question.IsPublished && !User.IsInRole("SUPER"))) return NotFound(); var userId = CurrentUserId(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); var wasFirstAttempt = await _attempts.RecordFirstAttemptAsync( question.ExternalId, userId, "", false, true); var rating = await _ratings.ApplyFirstResultAsync(userId, question, false, wasFirstAttempt); return Ok(new SkipQuestionResponse { QuestionExternalId = question.ExternalId, CorrectChoiceId = question.CorrectChoiceId, ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages( question, await _attempts.GetPercentagesForQuestionAsync(question.ExternalId)), QuestionElo = rating.QuestionElo, Rating = rating }); } [HttpPost] [Authorize(Roles = "SUPER")] public async Task Create([FromBody] UpsertQuestionRequest req) { var validation = ValidateQuestion(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 = ValidateQuestion(question); if (validation is not null) return BadRequest($"{question.ExternalId}: {validation}"); } var upserted = await _questions.UpsertManyByExternalIdAsync(req.Questions); return Ok(new { upserted }); } [HttpPut("{id}")] [Authorize(Roles = "SUPER")] public async Task Update(string id, [FromBody] UpsertQuestionRequest req) { var validation = ValidateQuestion(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"); } private static string? ValidateQuestion(UpsertQuestionRequest req) { if (string.IsNullOrWhiteSpace(req.ExternalId)) return "externalId required"; if (string.IsNullOrWhiteSpace(req.Stem)) return "stem required"; if (string.IsNullOrWhiteSpace(req.LeadIn)) return "leadIn required"; if (req.Choices.Count < 2) return "at least two choices required"; if (string.IsNullOrWhiteSpace(req.CorrectChoiceId)) return "correctChoiceId required"; if (!req.Choices.Any(c => c.Id == req.CorrectChoiceId)) return "correctChoiceId must match a choice id"; if (string.IsNullOrWhiteSpace(req.Explanation)) return "explanation required"; if (req.ChoiceSelectionPercentages.Any(entry => entry.Value is < 0 or > 100)) return "choiceSelectionPercentages values must be between 0 and 100"; var followupValidation = ValidateWrongAnswerFollowups(req); if (followupValidation is not null) return followupValidation; if (string.IsNullOrWhiteSpace(req.System)) return "system required"; if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required"; if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5"; return null; } private static string? ValidateWrongAnswerFollowups(UpsertQuestionRequest req) { var choiceIds = req.Choices.Select(c => c.Id).Where(id => !string.IsNullOrWhiteSpace(id)).ToHashSet(); foreach (var entry in req.WrongAnswerFollowups) { if (!choiceIds.Contains(entry.Key)) return "wrongAnswerFollowups keys must match a choice id"; if (entry.Key == req.CorrectChoiceId) return "wrongAnswerFollowups should only target incorrect choices"; if (string.IsNullOrWhiteSpace(entry.Value.Prompt)) return "wrongAnswerFollowups prompt required"; if (string.IsNullOrWhiteSpace(entry.Value.TeachingPoint)) return "wrongAnswerFollowups teachingPoint required"; if (entry.Value.Choices.Count == 0) return "wrongAnswerFollowups choices required"; if (!entry.Value.Choices.Any(c => c.Id == entry.Value.CorrectChoiceId)) return "wrongAnswerFollowups correctChoiceId must match a follow-up choice id"; } return null; } private string CurrentUserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? ""; }