224 lines
9.3 KiB
C#
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();
|
|
}
|