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 Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); [HttpPost] public async Task 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 PreviewResults() { try { return Ok(await simulations.BuildResultsPreviewAsync(UserId())); } catch (InvalidOperationException exception) { return Conflict(exception.Message); } } [HttpPost("sample-completed")] [Authorize(Roles = "SUPER")] public async Task CreateSampleCompleted() { try { return Ok(ExamSimulationStore.ToHistorySummary(await simulations.CreateSampleCompletedAsync(UserId()))); } catch (InvalidOperationException exception) { return Conflict(exception.Message); } } [HttpGet("active")] public async Task Active() { var simulation = await simulations.FindActiveAsync(UserId()); return simulation is null ? NoContent() : Ok(await simulations.ToResponseAsync(simulation)); } [HttpGet("completed")] public async Task 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 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 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 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 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 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 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 Get() { var outcome = await simulations.GetOutcomeAsync(UserId()); return outcome is null ? NoContent() : Ok(outcome); } [HttpPut("me")] public async Task Put(UpsertStep3OutcomeRequest request) { try { return Ok(await simulations.UpsertOutcomeAsync(UserId(), request)); } catch (ArgumentException exception) { return BadRequest(exception.Message); } } [HttpDelete("me")] public async Task Delete() { await simulations.DeleteOutcomeAsync(UserId()); return NoContent(); } private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException(); }