From da641a8d1da2c5c17f7eda7380095f648c96033a Mon Sep 17 00:00:00 2001 From: hz Date: Wed, 29 Jul 2026 18:34:54 -0500 Subject: [PATCH] Add Rz Coin utility store --- ENVIRONMENTS.md | 17 ++ api/QuestionsApi.Tests/CreditEconomyTests.cs | 23 +++ .../Controllers/CreditsController.cs | 40 ++++- .../Controllers/ExamSimulationsController.cs | 27 +++- .../Controllers/QuestionsController.cs | 6 +- api/QuestionsApi/Models/CreditModels.cs | 76 +++++++++ api/QuestionsApi/Models/QuestionAttempt.cs | 1 + api/QuestionsApi/Program.cs | 11 ++ api/QuestionsApi/Services/CreditStore.cs | 145 +++++++++++++++++- .../Services/ExamSimulationStore.cs | 11 +- .../QuestionAttemptCycleMigrationCommand.cs | 66 ++++++++ .../Services/QuestionAttemptStore.cs | 22 ++- scripts/autoload/credits_service.gd | 67 ++++++++ scripts/autoload/exam_simulation_service.gd | 9 ++ scripts/autoload/profile_service.gd | 8 + scripts/ui/credits_badge.gd | 9 ++ scripts/ui/main.gd | 8 + scripts/ui/navigation_routes.gd | 2 + scripts/ui/rz_store_screen.gd | 121 +++++++++++++++ scripts/ui/rz_store_screen.gd.uid | 1 + scripts/ui/simulation_history.gd | 7 + 21 files changed, 656 insertions(+), 21 deletions(-) create mode 100644 api/QuestionsApi/Services/QuestionAttemptCycleMigrationCommand.cs create mode 100644 scripts/ui/rz_store_screen.gd create mode 100644 scripts/ui/rz_store_screen.gd.uid diff --git a/ENVIRONMENTS.md b/ENVIRONMENTS.md index 5e07be0..7dd0593 100644 --- a/ENVIRONMENTS.md +++ b/ENVIRONMENTS.md @@ -77,6 +77,23 @@ Promotion is idempotent and does not delete or implicitly unpublish PRD content. ## Verification and rollback +## Rz Coin Store attempt-cycle migration + +The QBank reset preserves every attempt and increments a user's eligibility cycle. After deploying +the store-capable API to an environment, inspect and then apply the one-time index migration before +using QBank reset tokens: + +```bash +kubectl exec deployment/step3-api -n step3-uat -- \ + dotnet QuestionsApi.dll migrate-question-attempt-cycles +kubectl exec deployment/step3-api -n step3-uat -- \ + dotnet QuestionsApi.dll migrate-question-attempt-cycles --apply --confirm MIGRATE_ATTEMPT_CYCLES +``` + +Repeat with namespace `step3-prd` after the tested revision is promoted to `production`. The dry run +reports missing cycle fields and both index states. Apply backfills existing attempts to cycle zero, +creates the three-field unique index, and only then removes the legacy two-field unique index. + Check both internal and public readiness after every rollout: ```bash diff --git a/api/QuestionsApi.Tests/CreditEconomyTests.cs b/api/QuestionsApi.Tests/CreditEconomyTests.cs index c63772b..d1a2959 100644 --- a/api/QuestionsApi.Tests/CreditEconomyTests.cs +++ b/api/QuestionsApi.Tests/CreditEconomyTests.cs @@ -17,5 +17,28 @@ public sealed class CreditEconomyTests { Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId))); Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings))); + Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.Purchases))); + Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.AppliedOperations))); + } + + [Fact] + public void CatalogHasStableCoinOnlyUtilityPrices() + { + var exam = CreditEconomy.FindProduct(CreditEconomy.ExamTokenSku); + var reset = CreditEconomy.FindProduct(CreditEconomy.QBankResetTokenSku); + + Assert.NotNull(exam); + Assert.Equal(100, exam.CoinPrice); + Assert.Equal(RzStoreGrant.ExamToken, exam.Grant); + Assert.NotNull(reset); + Assert.Equal(300, reset.CoinPrice); + Assert.Equal(RzStoreGrant.QBankResetToken, reset.Grant); + Assert.Equal(2, CreditEconomy.Products.Count); + } + + [Fact] + public void UnknownSkuIsRejectedByCatalog() + { + Assert.Null(CreditEconomy.FindProduct("cash_purchase")); } } diff --git a/api/QuestionsApi/Controllers/CreditsController.cs b/api/QuestionsApi/Controllers/CreditsController.cs index 198fe60..755142c 100644 --- a/api/QuestionsApi/Controllers/CreditsController.cs +++ b/api/QuestionsApi/Controllers/CreditsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using QuestionsApi.Services; +using QuestionsApi.Models; using System.Security.Claims; namespace QuestionsApi.Controllers; @@ -8,13 +9,50 @@ namespace QuestionsApi.Controllers; [ApiController] [Route("api/credits")] [Authorize(Roles = "USER,SUPER")] -public sealed class CreditsController(CreditStore credits) : ControllerBase +public sealed class CreditsController(CreditStore credits, ExamSimulationStore simulations) : ControllerBase { [HttpGet] public async Task Get() => Ok(await credits.GetStatusAsync(UserId())); + [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 }); + } + 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; } diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs index 60d52be..e7286c4 100644 --- a/api/QuestionsApi/Controllers/ExamSimulationsController.cs +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -9,7 +9,7 @@ namespace QuestionsApi.Controllers; [ApiController] [Route("api/exam-simulations")] [Authorize(Roles = "USER,SUPER")] -public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase +public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase { [HttpGet("readiness")] public async Task Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); @@ -17,12 +17,33 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro [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()); + var simulation = await simulations.CreateAsync(userId); return Ok(await simulations.ToResponseAsync(simulation)); } - catch (InvalidOperationException exception) { return Conflict(exception.Message); } + 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")] diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 50b18d6..5489da7 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -157,7 +157,8 @@ public class QuestionsController : ControllerBase req.ChoiceId.Trim() == question.CorrectChoiceId, false, presentedChoiceIds, - req.DurationSeconds); + req.DurationSeconds, + await _credits.GetBankCycleAsync(userId)); if (record.Inserted) { _metrics.AttemptCreated(); @@ -276,7 +277,8 @@ public class QuestionsController : ControllerBase userId, "", false, - true); + true, + bankCycle: await _credits.GetBankCycleAsync(userId)); if (record.Inserted) _metrics.AttemptCreated(); else diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs index 50f4aea..e3ed6a1 100644 --- a/api/QuestionsApi/Models/CreditModels.cs +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -7,6 +7,44 @@ public static class CreditEconomy { public const int RollingEarningLimit = 20; public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24); + public const string ExamTokenSku = "exam_token_1"; + public const string QBankResetTokenSku = "qbank_reset_1"; + public const int ExamTokenPrice = 100; + public const int QBankResetTokenPrice = 300; + + public static readonly IReadOnlyList Products = + [ + new() + { + Sku = ExamTokenSku, + Name = "Exam Simulation Token", + Description = "Starts one new full exam simulation.", + CoinPrice = ExamTokenPrice, + Grant = RzStoreGrant.ExamToken + }, + new() + { + Sku = QBankResetTokenSku, + Name = "QBank Reset Token", + Description = "Makes every QBank question eligible again without erasing progress.", + CoinPrice = QBankResetTokenPrice, + Grant = RzStoreGrant.QBankResetToken + } + ]; + + public static RzStoreProduct? FindProduct(string sku) => + Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal)); +} + +public enum RzStoreGrant { ExamToken, QBankResetToken } + +public sealed class RzStoreProduct +{ + public string Sku { get; set; } = ""; + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public int CoinPrice { get; set; } + public RzStoreGrant Grant { get; set; } } public sealed class CreditEvent @@ -15,6 +53,14 @@ public sealed class CreditEvent public DateTime CreatedUtc { get; set; } } +public sealed class CreditPurchaseEvent +{ + public string OperationId { get; set; } = ""; + public string Sku { get; set; } = ""; + public int CoinCost { get; set; } + public DateTime CreatedUtc { get; set; } +} + public sealed class UserCreditAccount { [BsonId] @@ -22,7 +68,13 @@ public sealed class UserCreditAccount public string? Id { get; set; } public string UserId { get; set; } = ""; public int Credits { get; set; } + public int ExamTokens { get; set; } + public int QBankResetTokens { get; set; } + public int BankCycle { get; set; } + public bool InitialExamGrantApplied { get; set; } public List RecentEarnings { get; set; } = []; + public List Purchases { get; set; } = []; + public List AppliedOperations { get; set; } = []; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; } @@ -32,4 +84,28 @@ public sealed class CreditStatusResponse public int EarnedInRollingWindow { get; set; } public int RemainingEarningCapacity { get; set; } public int RollingWindowHours { get; set; } = 24; + public int ExamTokens { get; set; } + public int QBankResetTokens { get; set; } + public int BankCycle { get; set; } + public IReadOnlyList Products { get; set; } = CreditEconomy.Products; +} + +public sealed class CreditPurchaseRequest +{ + public string Sku { get; set; } = ""; + public string OperationId { get; set; } = ""; +} + +public sealed class QBankResetRequest +{ + public string Confirmation { get; set; } = ""; + public string OperationId { get; set; } = ""; +} + +public enum CreditOperationResult { Succeeded, InsufficientCredits, InsufficientTokens, OperationConflict } + +public sealed class CreditOperationResponse +{ + public CreditOperationResult Result { get; set; } + public CreditStatusResponse Status { get; set; } = new(); } diff --git a/api/QuestionsApi/Models/QuestionAttempt.cs b/api/QuestionsApi/Models/QuestionAttempt.cs index 691c708..9fefbc8 100644 --- a/api/QuestionsApi/Models/QuestionAttempt.cs +++ b/api/QuestionsApi/Models/QuestionAttempt.cs @@ -11,6 +11,7 @@ public class QuestionAttempt public string QuestionExternalId { get; set; } = ""; public string UserId { get; set; } = ""; + public int BankCycle { get; set; } public string ChoiceId { get; set; } = ""; public List PresentedChoiceIds { get; set; } = []; public bool TracksPresentation { get; set; } diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 7daeda7..c511ed3 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -55,6 +55,17 @@ if (args.FirstOrDefault() == "flag-media-questions") return; } +if (args.FirstOrDefault() == "migrate-question-attempt-cycles") +{ + var migrationConfiguration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + Environment.ExitCode = await QuestionAttemptCycleMigrationCommand.RunAsync( + migrationConfiguration, args.Skip(1).ToArray(), Console.Out); + return; +} + var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.Configure(options => diff --git a/api/QuestionsApi/Services/CreditStore.cs b/api/QuestionsApi/Services/CreditStore.cs index 5e6ee86..81e7802 100644 --- a/api/QuestionsApi/Services/CreditStore.cs +++ b/api/QuestionsApi/Services/CreditStore.cs @@ -51,15 +51,108 @@ public sealed class CreditStore var now = _clock.GetUtcNow().UtcDateTime; await EnsureAndCleanAsync(userId, now); var account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); - return new CreditStatusResponse - { - Credits = account.Credits, - EarnedInRollingWindow = account.RecentEarnings.Count, - RemainingEarningCapacity = Math.Max( - 0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count) - }; + return ToStatus(account); } + public async Task GetBankCycleAsync(string userId) + { + await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); + return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle; + } + + public async Task PurchaseAsync( + string userId, RzStoreProduct product, string operationId) + { + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync(); + var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId); + if (prior is not null) + return Response(prior.Sku == product.Sku + ? CreditOperationResult.Succeeded + : CreditOperationResult.OperationConflict, existing); + + var filter = Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Gte(value => value.Credits, product.CoinPrice) & + Builders.Filter.Not( + Builders.Filter.ElemMatch( + value => value.Purchases, purchase => purchase.OperationId == operationId)); + var update = Builders.Update + .Inc(value => value.Credits, -product.CoinPrice) + .Push(value => value.Purchases, new CreditPurchaseEvent + { + OperationId = operationId, + Sku = product.Sku, + CoinCost = product.CoinPrice, + CreatedUtc = now + }) + .Set(value => value.UpdatedUtc, now); + update = product.Grant == RzStoreGrant.ExamToken + ? update.Inc(value => value.ExamTokens, 1) + : update.Inc(value => value.QBankResetTokens, 1); + var account = await _accounts.FindOneAndUpdateAsync( + filter, update, new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After + }); + if (account is not null) return Response(CreditOperationResult.Succeeded, account); + + account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); + prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId); + if (prior is not null) + return Response(prior.Sku == product.Sku + ? CreditOperationResult.Succeeded + : CreditOperationResult.OperationConflict, account); + return Response(CreditOperationResult.InsufficientCredits, account); + } + + public async Task ApplyQBankResetAsync(string userId, string operationId) + { + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var filter = Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Gt(value => value.QBankResetTokens, 0) & + Builders.Filter.AnyNin(value => value.AppliedOperations, [operationId]); + var update = Builders.Update + .Inc(value => value.QBankResetTokens, -1) + .Inc(value => value.BankCycle, 1) + .AddToSet(value => value.AppliedOperations, operationId) + .Set(value => value.UpdatedUtc, now); + var account = await _accounts.FindOneAndUpdateAsync( + filter, update, new FindOneAndUpdateOptions + { + ReturnDocument = ReturnDocument.After + }); + if (account is not null) return Response(CreditOperationResult.Succeeded, account); + account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); + if (account.AppliedOperations.Contains(operationId)) + return Response(CreditOperationResult.Succeeded, account); + return Response(CreditOperationResult.InsufficientTokens, account); + } + + public async Task TryConsumeExamTokenAsync(string userId, string operationId) + { + var now = _clock.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var filter = Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Gt(value => value.ExamTokens, 0) & + Builders.Filter.AnyNin(value => value.AppliedOperations, [operationId]); + var update = Builders.Update + .Inc(value => value.ExamTokens, -1) + .AddToSet(value => value.AppliedOperations, operationId) + .Set(value => value.UpdatedUtc, now); + return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; + } + + public Task RefundExamTokenAsync(string userId, string operationId) => + _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.AnyEq(value => value.AppliedOperations, operationId), + Builders.Update + .Inc(value => value.ExamTokens, 1) + .Pull(value => value.AppliedOperations, operationId) + .Set(value => value.UpdatedUtc, _clock.GetUtcNow().UtcDateTime)); + private async Task EnsureAndCleanAsync(string userId, DateTime now) { await _accounts.UpdateOneAsync( @@ -67,13 +160,33 @@ public sealed class CreditStore Builders.Update .SetOnInsert(value => value.UserId, userId) .SetOnInsert(value => value.Credits, 0) + .SetOnInsert(value => value.ExamTokens, 1) + .SetOnInsert(value => value.QBankResetTokens, 0) + .SetOnInsert(value => value.BankCycle, 0) + .SetOnInsert(value => value.InitialExamGrantApplied, true) .SetOnInsert(value => value.RecentEarnings, []) + .SetOnInsert(value => value.Purchases, []) + .SetOnInsert(value => value.AppliedOperations, []) .SetOnInsert(value => value.UpdatedUtc, now), new UpdateOptions { IsUpsert = true }); + await _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Ne(value => value.InitialExamGrantApplied, true), + Builders.Update + .Inc(value => value.ExamTokens, 1) + .Set(value => value.InitialExamGrantApplied, true)); await _accounts.UpdateOneAsync( Builders.Filter.Eq(value => value.UserId, userId) & Builders.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false), Builders.Update.Set(value => value.RecentEarnings, [])); + await _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Exists(nameof(UserCreditAccount.Purchases), false), + Builders.Update.Set(value => value.Purchases, [])); + await _accounts.UpdateOneAsync( + Builders.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false), + Builders.Update.Set(value => value.AppliedOperations, [])); var cutoff = now - CreditEconomy.EarningWindow; await _accounts.UpdateOneAsync( value => value.UserId == userId, @@ -81,4 +194,22 @@ public sealed class CreditStore value => value.RecentEarnings, earning => earning.CreatedUtc <= cutoff)); } + + private static CreditStatusResponse ToStatus(UserCreditAccount account) => new() + { + Credits = account.Credits, + EarnedInRollingWindow = account.RecentEarnings.Count, + RemainingEarningCapacity = Math.Max( + 0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count), + ExamTokens = account.ExamTokens, + QBankResetTokens = account.QBankResetTokens, + BankCycle = account.BankCycle + }; + + private static CreditOperationResponse Response( + CreditOperationResult result, UserCreditAccount account) => new() + { + Result = result, + Status = ToStatus(account) + }; } diff --git a/api/QuestionsApi/Services/ExamSimulationStore.cs b/api/QuestionsApi/Services/ExamSimulationStore.cs index 27ea73e..037ab15 100644 --- a/api/QuestionsApi/Services/ExamSimulationStore.cs +++ b/api/QuestionsApi/Services/ExamSimulationStore.cs @@ -13,6 +13,7 @@ public class ExamSimulationStore private readonly IMongoCollection _outcomes; private readonly QuestionStore _questions; private readonly QuestionAttemptStore _attempts; + private readonly CreditStore _credits; private readonly AnswerStatisticsStore _statistics; private readonly RatingStore _ratings; private readonly ServiceMetrics _metrics; @@ -22,6 +23,7 @@ public class ExamSimulationStore MongoDatabaseProvider mongo, QuestionStore questions, QuestionAttemptStore attempts, + CreditStore credits, AnswerStatisticsStore statistics, RatingStore ratings, ServiceMetrics metrics, @@ -31,6 +33,7 @@ public class ExamSimulationStore _outcomes = mongo.Database.GetCollection("Step3Outcomes"); _questions = questions; _attempts = attempts; + _credits = credits; _statistics = statistics; _ratings = ratings; _metrics = metrics; @@ -68,7 +71,8 @@ public class ExamSimulationStore var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList(); if (bank.Count < RequiredQuestions) throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required."); - var seen = (await _attempts.GetUserQuestionIdsAsync(userId)).ToHashSet(StringComparer.Ordinal); + var bankCycle = await _credits.GetBankCycleAsync(userId); + var seen = (await _attempts.GetUserQuestionIdsAsync(userId, bankCycle)).ToHashSet(StringComparer.Ordinal); var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId)); var targets = BuildTargets(); var selected = SelectForm(bank, seen, targets); @@ -443,7 +447,10 @@ public class ExamSimulationStore if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue; responses.TryGetValue(item.QuestionExternalId, out var response); var choiceId = response?.ChoiceId ?? ""; - var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds); + var record = await _attempts.RecordFirstAttemptAsync( + item.QuestionExternalId, simulation.UserId, choiceId, + choiceId == question.CorrectChoiceId, choiceId == "", + item.PresentedChoiceIds, bankCycle: await _credits.GetBankCycleAsync(simulation.UserId)); await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); if (!record.Inserted) continue; _metrics.AttemptCreated(); diff --git a/api/QuestionsApi/Services/QuestionAttemptCycleMigrationCommand.cs b/api/QuestionsApi/Services/QuestionAttemptCycleMigrationCommand.cs new file mode 100644 index 0000000..aa4377e --- /dev/null +++ b/api/QuestionsApi/Services/QuestionAttemptCycleMigrationCommand.cs @@ -0,0 +1,66 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public static class QuestionAttemptCycleMigrationCommand +{ + public const string Confirmation = "MIGRATE_ATTEMPT_CYCLES"; + public const string LegacyIndexName = "QuestionExternalId_1_UserId_1"; + public const string CycleIndexName = "uq_question_user_cycle"; + + public static async Task RunAsync( + IConfiguration configuration, string[] args, TextWriter output) + { + var apply = args.Contains("--apply", StringComparer.Ordinal); + var confirmation = Option(args, "--confirm"); + var mongo = new MongoDatabaseProvider(configuration); + var attempts = mongo.Database.GetCollection("QuestionAttempts"); + var missingFilter = new BsonDocumentFilterDefinition( + new BsonDocument(nameof(QuestionAttempt.BankCycle), + new BsonDocument("$exists", false))); + var missing = await attempts.CountDocumentsAsync(missingFilter); + var indexes = (await attempts.Indexes.ListAsync()).ToList(); + var names = indexes.Select(value => value["name"].AsString).ToHashSet(StringComparer.Ordinal); + + await output.WriteLineAsync( + $"QuestionAttempts missing BankCycle: {missing}; legacy index: {names.Contains(LegacyIndexName)}; cycle index: {names.Contains(CycleIndexName)}."); + if (!apply) + { + await output.WriteLineAsync( + $"Dry run only. Re-run with --apply --confirm {Confirmation}."); + return 0; + } + if (confirmation != Confirmation) + { + await output.WriteLineAsync($"Refusing migration: --confirm {Confirmation} is required."); + return 2; + } + + if (missing > 0) + await attempts.UpdateManyAsync( + missingFilter, + Builders.Update.Set(value => value.BankCycle, 0)); + if (!names.Contains(CycleIndexName)) + await attempts.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys + .Ascending(value => value.QuestionExternalId) + .Ascending(value => value.UserId) + .Ascending(value => value.BankCycle), + new CreateIndexOptions { Unique = true, Name = CycleIndexName })); + if (names.Contains(LegacyIndexName)) + await attempts.Indexes.DropOneAsync(LegacyIndexName); + + var remaining = await attempts.CountDocumentsAsync(missingFilter); + await output.WriteLineAsync( + $"Migration complete. Backfilled {missing}; missing BankCycle: {remaining}; dropped legacy index: {names.Contains(LegacyIndexName)}."); + return remaining == 0 ? 0 : 1; + } + + private static string Option(string[] args, string name) + { + var index = Array.IndexOf(args, name); + return index >= 0 && index + 1 < args.Length ? args[index + 1] : ""; + } +} diff --git a/api/QuestionsApi/Services/QuestionAttemptStore.cs b/api/QuestionsApi/Services/QuestionAttemptStore.cs index c2921d2..54ef52f 100644 --- a/api/QuestionsApi/Services/QuestionAttemptStore.cs +++ b/api/QuestionsApi/Services/QuestionAttemptStore.cs @@ -17,10 +17,11 @@ public class QuestionAttemptStore var firstAttemptIndex = Builders.IndexKeys .Ascending(a => a.QuestionExternalId) - .Ascending(a => a.UserId); + .Ascending(a => a.UserId) + .Ascending(a => a.BankCycle); _col.Indexes.CreateOne(new CreateIndexModel( firstAttemptIndex, - new CreateIndexOptions { Unique = true })); + new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" })); var questionIndex = Builders.IndexKeys.Ascending(a => a.QuestionExternalId); _col.Indexes.CreateOne(new CreateIndexModel(questionIndex)); @@ -38,12 +39,14 @@ public class QuestionAttemptStore bool isCorrect, bool isSkip = false, IReadOnlyCollection? presentedChoiceIds = null, - int durationSeconds = 0) + int durationSeconds = 0, + int bankCycle = 0) { var attempt = new QuestionAttempt { QuestionExternalId = questionExternalId, UserId = userId, + BankCycle = bankCycle, ChoiceId = choiceId, PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [], TracksPresentation = presentedChoiceIds is { Count: > 0 }, @@ -63,7 +66,8 @@ public class QuestionAttemptStore catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { var existing = await _col - .Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId) + .Find(a => a.QuestionExternalId == questionExternalId && + a.UserId == userId && a.BankCycle == bankCycle) .FirstAsync(); return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing }; } @@ -75,11 +79,17 @@ public class QuestionAttemptStore Builders.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc)) .ToListAsync(); - public Task> GetUserQuestionIdsAsync(string userId) => + public Task> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) + { + var filter = Builders.Filter.Eq(attempt => attempt.UserId, userId); + if (bankCycle.HasValue) + filter &= Builders.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value); + return _col.Distinct( attempt => attempt.QuestionExternalId, - Builders.Filter.Eq(attempt => attempt.UserId, userId)) + filter) .ToListAsync(); + } public IMongoCollection Collection => _col; diff --git a/scripts/autoload/credits_service.gd b/scripts/autoload/credits_service.gd index 4f05a7a..fc40a41 100644 --- a/scripts/autoload/credits_service.gd +++ b/scripts/autoload/credits_service.gd @@ -2,16 +2,23 @@ extends Node signal credits_changed signal credits_failed(message: String) +signal store_operation_completed(message: String) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") const DEFAULT_URL := "https://us3q.ranaze.com/api/credits" var credits: int = 0 +var exam_tokens: int = 0 +var qbank_reset_tokens: int = 0 +var bank_cycle: int = 0 +var products: Array = [] var loaded := false var loading := false var request: HTTPRequest +var action_request: HTTPRequest var request_token := "" var api_url := DEFAULT_URL +var pending_store_action := "" func _ready() -> void: @@ -22,6 +29,10 @@ func _ready() -> void: request.timeout = 20.0 request.request_completed.connect(_on_completed) add_child(request) + action_request = HTTPRequest.new() + action_request.timeout = 20.0 + action_request.request_completed.connect(_on_action_completed) + add_child(action_request) AuthState.auth_changed.connect(_on_auth_changed) @@ -39,6 +50,10 @@ func refresh() -> void: func apply_status(parsed: Dictionary) -> void: credits = int(parsed.get("credits", credits)) + exam_tokens = int(parsed.get("examTokens", exam_tokens)) + qbank_reset_tokens = int(parsed.get("qBankResetTokens", qbank_reset_tokens)) + bank_cycle = int(parsed.get("bankCycle", bank_cycle)) + products = parsed.get("products", products) loaded = true credits_changed.emit() @@ -58,9 +73,61 @@ func _on_completed(result: int, response_code: int, _headers: PackedStringArray, apply_status(parsed) +func purchase(sku: String) -> void: + pending_store_action = "purchase" + _post("/purchases", {"sku": sku, "operationId": _operation_id("purchase")}) + + +func reset_qbank() -> void: + pending_store_action = "reset" + _post("/qbank-reset", {"confirmation": "RESET_QBANK", "operationId": _operation_id("reset")}) + + +func _post(path: String, payload: Dictionary) -> void: + if not AuthState.can_send_authenticated_request(): + return + var headers := PackedStringArray([ + "Authorization: Bearer %s" % AuthState.access_token, + "Content-Type: application/json" + ]) + var error := action_request.request(api_url + path, headers, HTTPClient.METHOD_POST, JSON.stringify(payload)) + if error != OK: + credits_failed.emit("The Rz Coin Store could not complete that action.") + + +func _on_action_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if result != HTTPRequest.RESULT_SUCCESS: + credits_failed.emit("The Rz Coin Store could not complete that action.") + return + if response_code >= 200 and response_code < 300 and typeof(parsed) == TYPE_DICTIONARY: + apply_status(parsed) + if pending_store_action == "reset": + ProfileService.reset_qbank_eligibility() + pending_store_action = "" + store_operation_completed.emit("Your Rz Coin Store balance was updated.") + return + if typeof(parsed) == TYPE_DICTIONARY: + var status: Variant = parsed.get("credits", {}) + if typeof(status) == TYPE_DICTIONARY: + apply_status(status) + credits_failed.emit(str(parsed.get("message", "The store action could not be completed."))) + else: + credits_failed.emit("The store action could not be completed.") + pending_store_action = "" + + +func _operation_id(prefix: String) -> String: + return "%s:%s:%d" % [prefix, str(AuthState.user_id), Time.get_ticks_usec()] + + func _on_auth_changed() -> void: if not AuthState.is_logged_in: credits = 0 + exam_tokens = 0 + qbank_reset_tokens = 0 + bank_cycle = 0 + products = [] loaded = false loading = false if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index a114119..c3dc7f5 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -8,6 +8,7 @@ signal state_changed signal request_failed(message: String) signal outcome_changed signal history_changed +signal exam_token_required var api_url := DEFAULT_API_URL var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes" @@ -152,6 +153,14 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri AuthState.handle_unauthorized(request_access_token) if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: var server_message := body.get_string_from_utf8().strip_edges() + var error_parsed: Variant = JSON.parse_string(server_message) + if typeof(error_parsed) == TYPE_DICTIONARY: + var credit_status: Variant = error_parsed.get("credits", {}) + if typeof(credit_status) == TYPE_DICTIONARY: + CreditsService.apply_status(credit_status) + if str(error_parsed.get("code", "")) == "exam_token_required": + exam_token_required.emit() + server_message = str(error_parsed.get("message", server_message)) if server_message.length() > 240: server_message = server_message.left(240) var message := "Simulation request failed with status %d." % response_code diff --git a/scripts/autoload/profile_service.gd b/scripts/autoload/profile_service.gd index ca344f0..fefc90e 100644 --- a/scripts/autoload/profile_service.gd +++ b/scripts/autoload/profile_service.gd @@ -130,6 +130,14 @@ func reset_qbank_memory() -> void: profile_changed.emit() +func reset_qbank_eligibility() -> void: + _ensure_tracking_fields() + profile["completed_question_ids"] = [] + profile["correct_question_ids"] = [] + save_profile() + profile_changed.emit() + + func apply_question_results(results: Dictionary) -> void: _ensure_daily_current() profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"])) diff --git a/scripts/ui/credits_badge.gd b/scripts/ui/credits_badge.gd index 583d3de..cf6962e 100644 --- a/scripts/ui/credits_badge.gd +++ b/scripts/ui/credits_badge.gd @@ -1,5 +1,6 @@ extends PanelContainer +signal store_requested const Brand := preload("res://scripts/ui/medmind_style.gd") const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg") const STAR_ICON := preload("res://assets/icons/lucide/star.svg") @@ -21,6 +22,9 @@ var _initialized := false func _ready() -> void: size_flags_vertical = Control.SIZE_SHRINK_CENTER + mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND + tooltip_text = "Open Rz Coin Store" + gui_input.connect(_on_gui_input) _build_ui() refresh_theme_controls() _on_credits_changed() @@ -30,6 +34,11 @@ func _ready() -> void: resized.connect(func(): pivot_offset = size * 0.5) +func _on_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + store_requested.emit() + + func _build_ui() -> void: var margin := MarginContainer.new() margin.add_theme_constant_override("margin_left", 7) diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index dacb8dc..ae84710 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -207,6 +207,7 @@ func _build_ui() -> void: header.add_child(account_chevron_button) credits_badge = CreditsBadge.new() + credits_badge.store_requested.connect(func(): show_screen("rz_store")) header.add_child(credits_badge) var screens_container := MarginContainer.new() @@ -355,6 +356,11 @@ func _build_ui() -> void: donations.main = self screens_container.add_child(donations) screens["donations"] = donations + var rz_store := preload("res://scripts/ui/rz_store_screen.gd").new() + rz_store.name = "RzCoinStore" + rz_store.main = self + screens_container.add_child(rz_store) + screens["rz_store"] = rz_store feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new() feedback_dialog.name = "AppFeedbackDialog" @@ -426,6 +432,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void _add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true, Brand.is_uat_environment()) _add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true) _add_nav_button(container, "notes", "Notes", "notebook-pen", true) + _add_nav_button(container, "rz_store", "Rz Coin Store", "star", true) var spacer := Control.new() spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL @@ -476,6 +483,7 @@ func _on_navigation_pressed(key: String) -> void: "notes": show_screen("notes") "bookmarks": show_screen("bookmarks") "donations": show_screen("donations") + "rz_store": show_screen("rz_store") "logout": _logout() diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index 8070cec..c9a1005 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -29,4 +29,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri key = "bookmarks" elif screen_name == "donations": key = "donations" + elif screen_name == "rz_store": + key = "rz_store" return key diff --git a/scripts/ui/rz_store_screen.gd b/scripts/ui/rz_store_screen.gd new file mode 100644 index 0000000..af393b9 --- /dev/null +++ b/scripts/ui/rz_store_screen.gd @@ -0,0 +1,121 @@ +extends VBoxContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") + +var main: Control +var balance_label: Label +var inventory_label: Label +var products_box: VBoxContainer +var status_label: Label +var confirm_dialog: ConfirmationDialog +var pending_sku := "" + + +func _ready() -> void: + size_flags_horizontal = Control.SIZE_EXPAND_FILL + size_flags_vertical = Control.SIZE_EXPAND_FILL + add_theme_constant_override("separation", 18) + var title := Label.new() + title.text = "Rz Coin Store" + title.add_theme_font_override("font", Brand.font) + title.add_theme_font_size_override("font_size", 30) + add_child(title) + var intro := Label.new() + intro.text = "Spend coins earned from correct, on-time first answers. Purchases use Rz Coins only." + intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(intro) + balance_label = Label.new() + balance_label.add_theme_font_size_override("font_size", 20) + add_child(balance_label) + inventory_label = Label.new() + add_child(inventory_label) + products_box = VBoxContainer.new() + products_box.add_theme_constant_override("separation", 12) + add_child(products_box) + status_label = Label.new() + status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(status_label) + confirm_dialog = ConfirmationDialog.new() + confirm_dialog.confirmed.connect(_confirm_purchase) + add_child(confirm_dialog) + CreditsService.credits_changed.connect(_refresh) + CreditsService.credits_failed.connect(_show_error) + CreditsService.store_operation_completed.connect(_show_message) + _refresh() + + +func on_show(_payload: Variant = null) -> void: + CreditsService.refresh() + _refresh() + + +func _refresh() -> void: + balance_label.text = "%d Rz Coins" % CreditsService.credits + inventory_label.text = "Inventory: %d exam token(s) · %d QBank reset token(s)" % [ + CreditsService.exam_tokens, CreditsService.qbank_reset_tokens] + for child in products_box.get_children(): + child.queue_free() + for product_variant in CreditsService.products: + if typeof(product_variant) != TYPE_DICTIONARY: + continue + var product: Dictionary = product_variant + var card := PanelContainer.new() + card.add_theme_stylebox_override("panel", Brand.content_card_box(UIThemeService.is_dark_mode(), true, Brand.ROYAL_BLUE)) + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 16) + card.add_child(row) + var copy := VBoxContainer.new() + copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL + var name_label := Label.new() + name_label.text = str(product.get("name", "Store item")) + name_label.add_theme_font_size_override("font_size", 19) + copy.add_child(name_label) + var description := Label.new() + description.text = str(product.get("description", "")) + description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + copy.add_child(description) + row.add_child(copy) + var buy := Button.new() + var price := int(product.get("coinPrice", 0)) + buy.text = "Buy · %d coins" % price + buy.disabled = CreditsService.credits < price + buy.pressed.connect(_ask_purchase.bind(str(product.get("sku", "")), str(product.get("name", "item")), price)) + row.add_child(buy) + products_box.add_child(card) + if CreditsService.qbank_reset_tokens > 0: + var reset := Button.new() + reset.text = "Use QBank Reset Token" + reset.tooltip_text = "Question eligibility resets; ELO, history, bookmarks, notes, reviews, and earning limits are preserved." + reset.pressed.connect(_ask_reset) + products_box.add_child(reset) + + +func _ask_purchase(sku: String, product_name: String, price: int) -> void: + pending_sku = sku + confirm_dialog.title = "Confirm purchase" + confirm_dialog.dialog_text = "Buy %s for %d Rz Coins?" % [product_name, price] + confirm_dialog.popup_centered() + + +func _confirm_purchase() -> void: + if pending_sku == "__reset__": + CreditsService.reset_qbank() + elif not pending_sku.is_empty(): + CreditsService.purchase(pending_sku) + pending_sku = "" + + +func _ask_reset() -> void: + pending_sku = "__reset__" + confirm_dialog.title = "Reset QBank eligibility" + confirm_dialog.dialog_text = "Make all QBank questions eligible again? ELO, answer history, bookmarks, notes, reviews, and the rolling coin cap will be preserved." + confirm_dialog.popup_centered() + + +func _show_error(message: String) -> void: + status_label.text = message + + +func _show_message(message: String) -> void: + status_label.text = message + _refresh() diff --git a/scripts/ui/rz_store_screen.gd.uid b/scripts/ui/rz_store_screen.gd.uid new file mode 100644 index 0000000..9c92c8f --- /dev/null +++ b/scripts/ui/rz_store_screen.gd.uid @@ -0,0 +1 @@ +uid://cldpnvs2k6r3t diff --git a/scripts/ui/simulation_history.gd b/scripts/ui/simulation_history.gd index 48a733a..139aa7e 100644 --- a/scripts/ui/simulation_history.gd +++ b/scripts/ui/simulation_history.gd @@ -137,6 +137,8 @@ func _connect_services() -> void: ExamSimulationService.state_changed.connect(_on_simulation_loaded) if not ExamSimulationService.request_failed.is_connected(_on_request_failed): ExamSimulationService.request_failed.connect(_on_request_failed) + if not ExamSimulationService.exam_token_required.is_connected(_on_exam_token_required): + ExamSimulationService.exam_token_required.connect(_on_exam_token_required) func on_show(_payload: Variant = null) -> void: @@ -538,6 +540,11 @@ func _on_request_failed(message: String) -> void: _render_active_section() +func _on_exam_token_required() -> void: + pending_launch = false + main.show_screen("rz_store") + + func _update_responsive_layout() -> void: var use_compact := size.x < 940 if compact_layout == use_compact: