239 lines
8.6 KiB
C#
239 lines
8.6 KiB
C#
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;
|
|
|
|
public QuestionsController(QuestionStore questions, QuestionAttemptStore attempts, RatingStore ratings)
|
|
{
|
|
_questions = questions;
|
|
_attempts = attempts;
|
|
_ratings = ratings;
|
|
}
|
|
|
|
[HttpGet("systems")]
|
|
[Authorize(Roles = "USER,SUPER")]
|
|
public async Task<IActionResult> Systems()
|
|
{
|
|
var systems = await _questions.GetSystemsAsync(User.IsInRole("SUPER"));
|
|
return Ok(systems);
|
|
}
|
|
|
|
[HttpPost("search")]
|
|
[Authorize(Roles = "USER,SUPER")]
|
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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("{externalId}/skip")]
|
|
[Authorize(Roles = "USER,SUPER")]
|
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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";
|
|
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 string CurrentUserId() =>
|
|
User.FindFirstValue(ClaimTypes.NameIdentifier)
|
|
?? User.FindFirstValue(ClaimTypes.Name)
|
|
?? User.Identity?.Name
|
|
?? "";
|
|
}
|