501 lines
19 KiB
C#
501 lines
19 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;
|
|
private readonly QuestionMediaStore _media;
|
|
private readonly AnswerStatisticsStore _statistics;
|
|
private readonly ServiceMetrics _metrics;
|
|
private readonly QuestionBookmarkStore _bookmarks;
|
|
private readonly ReviewQueueStore _reviewQueue;
|
|
private readonly CreditStore _credits;
|
|
private readonly MasteryAwardService _masteryAwards;
|
|
private readonly StudySessionStore _studySessions;
|
|
private readonly DailyMissionStore _dailyMissions;
|
|
private readonly ProgressionStore _progression;
|
|
|
|
public QuestionsController(
|
|
QuestionStore questions,
|
|
QuestionAttemptStore attempts,
|
|
RatingStore ratings,
|
|
QuestionMediaStore media,
|
|
AnswerStatisticsStore statistics,
|
|
ServiceMetrics metrics,
|
|
QuestionBookmarkStore bookmarks,
|
|
ReviewQueueStore reviewQueue,
|
|
CreditStore credits,
|
|
MasteryAwardService masteryAwards,
|
|
StudySessionStore studySessions,
|
|
DailyMissionStore dailyMissions,
|
|
ProgressionStore progression)
|
|
{
|
|
_questions = questions;
|
|
_attempts = attempts;
|
|
_ratings = ratings;
|
|
_media = media;
|
|
_statistics = statistics;
|
|
_metrics = metrics;
|
|
_bookmarks = bookmarks;
|
|
_reviewQueue = reviewQueue;
|
|
_credits = credits;
|
|
_masteryAwards = masteryAwards;
|
|
_studySessions = studySessions;
|
|
_dailyMissions = dailyMissions;
|
|
_progression = progression;
|
|
}
|
|
|
|
[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)
|
|
{
|
|
List<Question> questions;
|
|
try { questions = await _questions.SearchAsync(req, User.IsInRole("SUPER")); }
|
|
catch (ArgumentException exception) { return BadRequest(exception.Message); }
|
|
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<IActionResult> 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<IActionResult> 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<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 || (!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();
|
|
|
|
await _masteryAwards.PrepareAsync(userId);
|
|
await _progression.PrepareAsync(userId);
|
|
|
|
var record = await _attempts.RecordFirstAttemptAsync(
|
|
question.ExternalId,
|
|
userId,
|
|
req.ChoiceId.Trim(),
|
|
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
|
false,
|
|
presentedChoiceIds,
|
|
req.DurationSeconds,
|
|
await _credits.GetBankCycleAsync(userId));
|
|
if (record.Inserted)
|
|
{
|
|
_metrics.AttemptCreated();
|
|
try
|
|
{
|
|
await _statistics.IncrementAsync(record.Attempt);
|
|
}
|
|
catch
|
|
{
|
|
_metrics.CounterFailure();
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_metrics.AttemptDuplicate();
|
|
}
|
|
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
|
var creditAwards = new List<CreditAward>();
|
|
if (record.Inserted && record.Attempt.IsCorrect)
|
|
{
|
|
var amount = CreditEconomy.FirstCorrectAmount(question.Difficulty);
|
|
var difficult = question.Difficulty >= 4;
|
|
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
|
|
$"answer:{record.Attempt.Id}", amount,
|
|
difficult ? CreditReasonCodes.DifficultCorrect : CreditReasonCodes.FirstCorrect,
|
|
difficult ? $"Difficulty {question.Difficulty} first correct answer" : "First correct answer",
|
|
"question_attempt", record.Attempt.Id ?? ""));
|
|
if (award.Award is not null) creditAwards.Add(award.Award);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
creditAwards.AddRange(await _masteryAwards.AwardPromotionsAsync(userId));
|
|
StudySessionEnvelope? studySession = null;
|
|
if (!string.IsNullOrWhiteSpace(req.StudySessionId))
|
|
studySession = await _studySessions.RecordQuestionAsync(
|
|
req.StudySessionId, userId, record.Attempt, question, creditAwards);
|
|
if (studySession?.Summary is not null)
|
|
creditAwards.AddRange(studySession.Summary.CoinAwards.Where(value =>
|
|
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
|
|
&& creditAwards.All(existing => existing.ReasonCode != value.ReasonCode)));
|
|
var dailyMissions = await _dailyMissions.RecordQuestionAsync(userId, record.Attempt, question);
|
|
if (dailyMissions is not null) creditAwards.AddRange(dailyMissions.CreditAwards);
|
|
var progression = await _progression.RefreshAsync(userId);
|
|
creditAwards.AddRange(progression.Awards);
|
|
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),
|
|
CreditAwards = creditAwards,
|
|
StudySessionProgress = studySession?.Progress,
|
|
SessionSummary = studySession?.Summary,
|
|
DailyMissions = dailyMissions?.State,
|
|
Progression = progression.State
|
|
});
|
|
}
|
|
|
|
[HttpPost("media")]
|
|
[Authorize(Roles = "SUPER")]
|
|
[RequestSizeLimit(5_000_000)]
|
|
public async Task<IActionResult> 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<string>
|
|
{
|
|
"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<IActionResult> 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<IActionResult> 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();
|
|
|
|
await _progression.PrepareAsync(userId);
|
|
|
|
var record = await _attempts.RecordFirstAttemptAsync(
|
|
question.ExternalId,
|
|
userId,
|
|
"",
|
|
false,
|
|
true,
|
|
bankCycle: await _credits.GetBankCycleAsync(userId));
|
|
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;
|
|
}
|
|
|
|
var progression = await _progression.RefreshAsync(userId);
|
|
return Ok(new SkipQuestionResponse
|
|
{
|
|
QuestionExternalId = question.ExternalId,
|
|
CorrectChoiceId = question.CorrectChoiceId,
|
|
ChoiceSelectionPercentages = QuestionResponse.BuildChoiceSelectionPercentages(
|
|
question,
|
|
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
|
QuestionElo = rating.QuestionElo,
|
|
Rating = rating,
|
|
Progression = progression.State
|
|
});
|
|
}
|
|
|
|
[HttpPost]
|
|
[Authorize(Roles = "SUPER")]
|
|
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> BulkUpdateReferences([FromBody] BulkQuestionReferencesRequest req)
|
|
{
|
|
if (req.Questions.Count == 0)
|
|
return BadRequest("questions required");
|
|
|
|
var externalIds = new HashSet<string>(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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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
|
|
?? "";
|
|
}
|