using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using QuestionsApi.Services; using QuestionsApi.Models; using System.Security.Claims; namespace QuestionsApi.Controllers; [ApiController] [Route("api/credits")] [Authorize(Roles = "USER,SUPER")] public sealed class CreditsController(CreditStore credits, ExamSimulationStore simulations) : ControllerBase { [HttpGet] public async Task Get() => Ok(await credits.GetStatusAsync(UserId())); [HttpGet("ledger")] public async Task Ledger([FromQuery] int limit = 20, [FromQuery] string? before = null) { DateTime? cursor = null; if (!string.IsNullOrWhiteSpace(before)) { if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) return BadRequest(new { code = "invalid_cursor", message = "The ledger cursor is invalid." }); cursor = parsed; } return Ok(await credits.GetLedgerAsync(UserId(), limit, cursor)); } [HttpPost("purchases")] public async Task Purchase([FromBody] CreditPurchaseRequest request) { var product = CreditEconomy.FindProduct(request.Sku); if (product is null) return BadRequest(new { code = "unknown_sku", message = "Unknown store product." }); if (!ValidOperationId(request.OperationId)) return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." }); var result = await credits.PurchaseAsync(UserId(), product, request.OperationId); return result.Result switch { CreditOperationResult.Succeeded => Ok(result.Status), CreditOperationResult.InsufficientCredits => Conflict(new { code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }), _ => Conflict(new { code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status }) }; } [HttpPost("qbank-reset")] public async Task ResetQBank([FromBody] QBankResetRequest request) { if (request.Confirmation != "RESET_QBANK") return BadRequest(new { code = "confirmation_required", message = "Enter RESET_QBANK to confirm." }); if (!ValidOperationId(request.OperationId)) return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." }); if (await simulations.FindActiveAsync(UserId()) is not null) return Conflict(new { code = "active_exam", message = "Finish or end the active exam before resetting the QBank." }); var result = await credits.ApplyQBankResetAsync(UserId(), request.OperationId); return result.Result == CreditOperationResult.Succeeded ? Ok(result.Status) : Conflict(new { code = "reset_token_required", message = "A QBank Reset Token is required.", credits = result.Status }); } [HttpGet("spin-config")] public IActionResult SpinConfig() => Ok(new SpinConfigResponse()); [HttpPost("spin")] public async Task Spin([FromBody] SpinRequest request) { if (!ValidOperationId(request.OperationId)) return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." }); var result = await credits.SpinAsync(UserId(), request.OperationId); return result.Result switch { CreditOperationResult.Succeeded => Ok(result), CreditOperationResult.InsufficientCredits => Conflict(new { code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }), CreditOperationResult.RateLimited => Conflict(new { code = "rate_limited", message = "Too many spins - try again later.", credits = result.Status }), _ => Conflict(new { code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status }) }; } private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException(); private static bool ValidOperationId(string value) => !string.IsNullOrWhiteSpace(value) && value.Length <= 100; }