Files
step3/api/QuestionsApi/Controllers/ExamSimulationsController.cs
T

224 lines
9.3 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/exam-simulations")]
[Authorize(Roles = "USER,SUPER")]
public class ExamSimulationsController(
ExamSimulationStore simulations,
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions,
ProgressionStore progression) : ControllerBase
{
[HttpGet("readiness")]
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
[HttpPost]
public async Task<IActionResult> Create()
{
var userId = UserId();
if (await simulations.FindActiveAsync(userId) is not null)
return Conflict(new { code = "active_exam", message = "An active Step 3 simulation already exists." });
var operationId = $"exam-create:{Guid.NewGuid():N}";
var bypass = User.IsInRole("SUPER");
if (!bypass && !await credits.TryConsumeExamTokenAsync(userId, operationId))
return Conflict(new
{
code = "exam_token_required",
message = "An Exam Simulation Token is required.",
credits = await credits.GetStatusAsync(userId)
});
try
{
var simulation = await simulations.CreateAsync(userId);
return Ok(await simulations.ToResponseAsync(simulation));
}
catch (InvalidOperationException exception)
{
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
return Conflict(new { code = "exam_creation_failed", message = exception.Message });
}
catch
{
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
throw;
}
}
[HttpGet("preview-results")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> PreviewResults()
{
try { return Ok(await simulations.BuildResultsPreviewAsync(UserId())); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("sample-completed")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> CreateSampleCompleted()
{
try { return Ok(ExamSimulationStore.ToHistorySummary(await simulations.CreateSampleCompletedAsync(UserId()))); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpGet("active")]
public async Task<IActionResult> Active()
{
var simulation = await simulations.FindActiveAsync(UserId());
return simulation is null ? NoContent() : Ok(await simulations.ToResponseAsync(simulation));
}
[HttpGet("completed")]
public async Task<IActionResult> Completed([FromQuery] int limit = 20, [FromQuery] string? before = null)
{
DateTime? completedBefore = null;
if (!string.IsNullOrWhiteSpace(before))
{
if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
return BadRequest("The completed-simulation cursor is invalid.");
completedBefore = parsed;
}
return Ok(await simulations.GetCompletedAsync(UserId(), limit, completedBefore));
}
[HttpGet("history")]
public async Task<IActionResult> History(
[FromQuery] int limit = 20,
[FromQuery] string status = "all",
[FromQuery] string sort = "newest",
[FromQuery] string? cursor = null,
[FromQuery] string? before = null)
{
DateTime? legacyBefore = null;
if (!string.IsNullOrWhiteSpace(before))
{
if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
return BadRequest("The simulation-history cursor is invalid.");
legacyBefore = parsed;
}
try { return Ok(await simulations.GetHistoryAsync(UserId(), limit, status, sort, cursor, legacyBefore)); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(string id)
{
var simulation = await simulations.GetOwnedAsync(id, UserId());
return simulation is null ? NotFound() : Ok(await simulations.ToResponseAsync(simulation));
}
[HttpPost("{id}/blocks/start")]
public async Task<IActionResult> StartBlock(string id)
{
var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound();
try
{
simulation = await simulations.StartNextBlockAsync(simulation);
await studySessions.EnsureSimulationBlockAsync(UserId(), simulation.Id ?? id, simulation.CurrentBlock);
return Ok(await simulations.ToResponseAsync(simulation));
}
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPut("{id}/responses")]
public async Task<IActionResult> SaveResponse(string id, SaveSimulationResponseRequest request)
{
var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound();
try { return Ok(await simulations.ToResponseAsync(await simulations.SaveResponseAsync(simulation, request))); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("{id}/blocks/submit")]
public async Task<IActionResult> SubmitBlock(string id)
{
var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound();
try
{
var userId = UserId();
await masteryAwards.PrepareAsync(userId);
await progression.PrepareAsync(userId);
simulation = await simulations.SubmitBlockAsync(simulation);
var response = await simulations.ToResponseAsync(simulation);
var session = await studySessions.CompleteSimulationBlockAsync(userId, simulation.Id ?? id, simulation.CurrentBlock);
response.StudySessionProgress = session.Progress;
response.SessionSummary = session.Summary;
if (session.Summary is not null)
response.CreditAwards.AddRange(session.Summary.CoinAwards.Where(value =>
value.ReasonCode == CreditReasonCodes.SessionGoalComplete));
if (simulation.State == ExamSimulationStates.Completed && !simulation.IsSample)
{
var result = await credits.AwardAsync(userId, new CreditAwardRequest(
$"simulation:{simulation.Id}", 10, CreditReasonCodes.ExamSimulationComplete,
"Completed a Step 3 exam simulation", "exam_simulation", simulation.Id ?? "", false));
if (result.Award is not null) response.CreditAwards.Add(result.Award);
response.CreditAwards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
response.Credits = await credits.GetStatusAsync(userId);
}
else if (session.Summary is not null)
response.Credits = await credits.GetStatusAsync(userId);
var progressionResult = await progression.RefreshAsync(userId);
response.Progression = progressionResult.State;
response.CreditAwards.AddRange(progressionResult.Awards);
if (progressionResult.Awards.Count > 0) response.Credits = await credits.GetStatusAsync(userId);
return Ok(response);
}
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("{id}/abandon")]
public async Task<IActionResult> Abandon(string id)
{
var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound();
await simulations.AbandonAsync(simulation);
return NoContent();
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
[ApiController]
[Route("api/step3-outcomes")]
[Authorize(Roles = "USER,SUPER")]
public class Step3OutcomesController(ExamSimulationStore simulations) : ControllerBase
{
[HttpGet("me")]
public async Task<IActionResult> Get()
{
var outcome = await simulations.GetOutcomeAsync(UserId());
return outcome is null ? NoContent() : Ok(outcome);
}
[HttpPut("me")]
public async Task<IActionResult> Put(UpsertStep3OutcomeRequest request)
{
try { return Ok(await simulations.UpsertOutcomeAsync(UserId(), request)); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
}
[HttpDelete("me")]
public async Task<IActionResult> Delete()
{
await simulations.DeleteOutcomeAsync(UserId());
return NoContent();
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}