Add Rz Coin utility store
This commit is contained in:
@@ -77,6 +77,23 @@ Promotion is idempotent and does not delete or implicitly unpublish PRD content.
|
|||||||
|
|
||||||
## Verification and rollback
|
## 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:
|
Check both internal and public readiness after every rollout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -17,5 +17,28 @@ public sealed class CreditEconomyTests
|
|||||||
{
|
{
|
||||||
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
|
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
|
||||||
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings)));
|
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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using QuestionsApi.Services;
|
using QuestionsApi.Services;
|
||||||
|
using QuestionsApi.Models;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace QuestionsApi.Controllers;
|
namespace QuestionsApi.Controllers;
|
||||||
@@ -8,13 +9,50 @@ namespace QuestionsApi.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/credits")]
|
[Route("api/credits")]
|
||||||
[Authorize(Roles = "USER,SUPER")]
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
public sealed class CreditsController(CreditStore credits) : ControllerBase
|
public sealed class CreditsController(CreditStore credits, ExamSimulationStore simulations) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
|
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
|
||||||
|
|
||||||
|
[HttpPost("purchases")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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)
|
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
?? User.FindFirstValue(ClaimTypes.Name)
|
?? User.FindFirstValue(ClaimTypes.Name)
|
||||||
?? User.Identity?.Name
|
?? User.Identity?.Name
|
||||||
?? throw new UnauthorizedAccessException();
|
?? throw new UnauthorizedAccessException();
|
||||||
|
|
||||||
|
private static bool ValidOperationId(string value) =>
|
||||||
|
!string.IsNullOrWhiteSpace(value) && value.Length <= 100;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace QuestionsApi.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/exam-simulations")]
|
[Route("api/exam-simulations")]
|
||||||
[Authorize(Roles = "USER,SUPER")]
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
|
public class ExamSimulationsController(ExamSimulationStore simulations, CreditStore credits) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("readiness")]
|
[HttpGet("readiness")]
|
||||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||||
@@ -17,12 +17,33 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create()
|
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
|
try
|
||||||
{
|
{
|
||||||
var simulation = await simulations.CreateAsync(UserId());
|
var simulation = await simulations.CreateAsync(userId);
|
||||||
return Ok(await simulations.ToResponseAsync(simulation));
|
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")]
|
[HttpGet("preview-results")]
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ public class QuestionsController : ControllerBase
|
|||||||
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
||||||
false,
|
false,
|
||||||
presentedChoiceIds,
|
presentedChoiceIds,
|
||||||
req.DurationSeconds);
|
req.DurationSeconds,
|
||||||
|
await _credits.GetBankCycleAsync(userId));
|
||||||
if (record.Inserted)
|
if (record.Inserted)
|
||||||
{
|
{
|
||||||
_metrics.AttemptCreated();
|
_metrics.AttemptCreated();
|
||||||
@@ -276,7 +277,8 @@ public class QuestionsController : ControllerBase
|
|||||||
userId,
|
userId,
|
||||||
"",
|
"",
|
||||||
false,
|
false,
|
||||||
true);
|
true,
|
||||||
|
bankCycle: await _credits.GetBankCycleAsync(userId));
|
||||||
if (record.Inserted)
|
if (record.Inserted)
|
||||||
_metrics.AttemptCreated();
|
_metrics.AttemptCreated();
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -7,6 +7,44 @@ public static class CreditEconomy
|
|||||||
{
|
{
|
||||||
public const int RollingEarningLimit = 20;
|
public const int RollingEarningLimit = 20;
|
||||||
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
|
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<RzStoreProduct> 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
|
public sealed class CreditEvent
|
||||||
@@ -15,6 +53,14 @@ public sealed class CreditEvent
|
|||||||
public DateTime CreatedUtc { get; set; }
|
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
|
public sealed class UserCreditAccount
|
||||||
{
|
{
|
||||||
[BsonId]
|
[BsonId]
|
||||||
@@ -22,7 +68,13 @@ public sealed class UserCreditAccount
|
|||||||
public string? Id { get; set; }
|
public string? Id { get; set; }
|
||||||
public string UserId { get; set; } = "";
|
public string UserId { get; set; } = "";
|
||||||
public int Credits { 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<CreditEvent> RecentEarnings { get; set; } = [];
|
public List<CreditEvent> RecentEarnings { get; set; } = [];
|
||||||
|
public List<CreditPurchaseEvent> Purchases { get; set; } = [];
|
||||||
|
public List<string> AppliedOperations { get; set; } = [];
|
||||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,4 +84,28 @@ public sealed class CreditStatusResponse
|
|||||||
public int EarnedInRollingWindow { get; set; }
|
public int EarnedInRollingWindow { get; set; }
|
||||||
public int RemainingEarningCapacity { get; set; }
|
public int RemainingEarningCapacity { get; set; }
|
||||||
public int RollingWindowHours { get; set; } = 24;
|
public int RollingWindowHours { get; set; } = 24;
|
||||||
|
public int ExamTokens { get; set; }
|
||||||
|
public int QBankResetTokens { get; set; }
|
||||||
|
public int BankCycle { get; set; }
|
||||||
|
public IReadOnlyList<RzStoreProduct> 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public class QuestionAttempt
|
|||||||
|
|
||||||
public string QuestionExternalId { get; set; } = "";
|
public string QuestionExternalId { get; set; } = "";
|
||||||
public string UserId { get; set; } = "";
|
public string UserId { get; set; } = "";
|
||||||
|
public int BankCycle { get; set; }
|
||||||
public string ChoiceId { get; set; } = "";
|
public string ChoiceId { get; set; } = "";
|
||||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||||
public bool TracksPresentation { get; set; }
|
public bool TracksPresentation { get; set; }
|
||||||
|
|||||||
@@ -55,6 +55,17 @@ if (args.FirstOrDefault() == "flag-media-questions")
|
|||||||
return;
|
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);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
|
|||||||
@@ -51,15 +51,108 @@ public sealed class CreditStore
|
|||||||
var now = _clock.GetUtcNow().UtcDateTime;
|
var now = _clock.GetUtcNow().UtcDateTime;
|
||||||
await EnsureAndCleanAsync(userId, now);
|
await EnsureAndCleanAsync(userId, now);
|
||||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||||
return new CreditStatusResponse
|
return ToStatus(account);
|
||||||
{
|
|
||||||
Credits = account.Credits,
|
|
||||||
EarnedInRollingWindow = account.RecentEarnings.Count,
|
|
||||||
RemainingEarningCapacity = Math.Max(
|
|
||||||
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetBankCycleAsync(string userId)
|
||||||
|
{
|
||||||
|
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||||
|
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CreditOperationResponse> 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<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Not(
|
||||||
|
Builders<UserCreditAccount>.Filter.ElemMatch(
|
||||||
|
value => value.Purchases, purchase => purchase.OperationId == operationId));
|
||||||
|
var update = Builders<UserCreditAccount>.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<UserCreditAccount>
|
||||||
|
{
|
||||||
|
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<CreditOperationResponse> ApplyQBankResetAsync(string userId, string operationId)
|
||||||
|
{
|
||||||
|
var now = _clock.GetUtcNow().UtcDateTime;
|
||||||
|
await EnsureAndCleanAsync(userId, now);
|
||||||
|
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Gt(value => value.QBankResetTokens, 0) &
|
||||||
|
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
|
||||||
|
var update = Builders<UserCreditAccount>.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<UserCreditAccount>
|
||||||
|
{
|
||||||
|
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<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
||||||
|
{
|
||||||
|
var now = _clock.GetUtcNow().UtcDateTime;
|
||||||
|
await EnsureAndCleanAsync(userId, now);
|
||||||
|
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Gt(value => value.ExamTokens, 0) &
|
||||||
|
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
|
||||||
|
var update = Builders<UserCreditAccount>.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<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
|
Builders<UserCreditAccount>.Filter.AnyEq(value => value.AppliedOperations, operationId),
|
||||||
|
Builders<UserCreditAccount>.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)
|
private async Task EnsureAndCleanAsync(string userId, DateTime now)
|
||||||
{
|
{
|
||||||
await _accounts.UpdateOneAsync(
|
await _accounts.UpdateOneAsync(
|
||||||
@@ -67,13 +160,33 @@ public sealed class CreditStore
|
|||||||
Builders<UserCreditAccount>.Update
|
Builders<UserCreditAccount>.Update
|
||||||
.SetOnInsert(value => value.UserId, userId)
|
.SetOnInsert(value => value.UserId, userId)
|
||||||
.SetOnInsert(value => value.Credits, 0)
|
.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.RecentEarnings, [])
|
||||||
|
.SetOnInsert(value => value.Purchases, [])
|
||||||
|
.SetOnInsert(value => value.AppliedOperations, [])
|
||||||
.SetOnInsert(value => value.UpdatedUtc, now),
|
.SetOnInsert(value => value.UpdatedUtc, now),
|
||||||
new UpdateOptions { IsUpsert = true });
|
new UpdateOptions { IsUpsert = true });
|
||||||
|
await _accounts.UpdateOneAsync(
|
||||||
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Ne(value => value.InitialExamGrantApplied, true),
|
||||||
|
Builders<UserCreditAccount>.Update
|
||||||
|
.Inc(value => value.ExamTokens, 1)
|
||||||
|
.Set(value => value.InitialExamGrantApplied, true));
|
||||||
await _accounts.UpdateOneAsync(
|
await _accounts.UpdateOneAsync(
|
||||||
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
|
||||||
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
|
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
|
||||||
|
await _accounts.UpdateOneAsync(
|
||||||
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
|
||||||
|
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
|
||||||
|
await _accounts.UpdateOneAsync(
|
||||||
|
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||||
|
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
|
||||||
|
Builders<UserCreditAccount>.Update.Set(value => value.AppliedOperations, []));
|
||||||
var cutoff = now - CreditEconomy.EarningWindow;
|
var cutoff = now - CreditEconomy.EarningWindow;
|
||||||
await _accounts.UpdateOneAsync(
|
await _accounts.UpdateOneAsync(
|
||||||
value => value.UserId == userId,
|
value => value.UserId == userId,
|
||||||
@@ -81,4 +194,22 @@ public sealed class CreditStore
|
|||||||
value => value.RecentEarnings,
|
value => value.RecentEarnings,
|
||||||
earning => earning.CreatedUtc <= cutoff));
|
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)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public class ExamSimulationStore
|
|||||||
private readonly IMongoCollection<Step3Outcome> _outcomes;
|
private readonly IMongoCollection<Step3Outcome> _outcomes;
|
||||||
private readonly QuestionStore _questions;
|
private readonly QuestionStore _questions;
|
||||||
private readonly QuestionAttemptStore _attempts;
|
private readonly QuestionAttemptStore _attempts;
|
||||||
|
private readonly CreditStore _credits;
|
||||||
private readonly AnswerStatisticsStore _statistics;
|
private readonly AnswerStatisticsStore _statistics;
|
||||||
private readonly RatingStore _ratings;
|
private readonly RatingStore _ratings;
|
||||||
private readonly ServiceMetrics _metrics;
|
private readonly ServiceMetrics _metrics;
|
||||||
@@ -22,6 +23,7 @@ public class ExamSimulationStore
|
|||||||
MongoDatabaseProvider mongo,
|
MongoDatabaseProvider mongo,
|
||||||
QuestionStore questions,
|
QuestionStore questions,
|
||||||
QuestionAttemptStore attempts,
|
QuestionAttemptStore attempts,
|
||||||
|
CreditStore credits,
|
||||||
AnswerStatisticsStore statistics,
|
AnswerStatisticsStore statistics,
|
||||||
RatingStore ratings,
|
RatingStore ratings,
|
||||||
ServiceMetrics metrics,
|
ServiceMetrics metrics,
|
||||||
@@ -31,6 +33,7 @@ public class ExamSimulationStore
|
|||||||
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
|
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
|
||||||
_questions = questions;
|
_questions = questions;
|
||||||
_attempts = attempts;
|
_attempts = attempts;
|
||||||
|
_credits = credits;
|
||||||
_statistics = statistics;
|
_statistics = statistics;
|
||||||
_ratings = ratings;
|
_ratings = ratings;
|
||||||
_metrics = metrics;
|
_metrics = metrics;
|
||||||
@@ -68,7 +71,8 @@ public class ExamSimulationStore
|
|||||||
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
|
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
|
||||||
if (bank.Count < RequiredQuestions)
|
if (bank.Count < RequiredQuestions)
|
||||||
throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required.");
|
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 elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId));
|
||||||
var targets = BuildTargets();
|
var targets = BuildTargets();
|
||||||
var selected = SelectForm(bank, seen, targets);
|
var selected = SelectForm(bank, seen, targets);
|
||||||
@@ -443,7 +447,10 @@ public class ExamSimulationStore
|
|||||||
if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue;
|
if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue;
|
||||||
responses.TryGetValue(item.QuestionExternalId, out var response);
|
responses.TryGetValue(item.QuestionExternalId, out var response);
|
||||||
var choiceId = response?.ChoiceId ?? "";
|
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);
|
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||||
if (!record.Inserted) continue;
|
if (!record.Inserted) continue;
|
||||||
_metrics.AttemptCreated();
|
_metrics.AttemptCreated();
|
||||||
|
|||||||
@@ -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<int> 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<QuestionAttempt>("QuestionAttempts");
|
||||||
|
var missingFilter = new BsonDocumentFilterDefinition<QuestionAttempt>(
|
||||||
|
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<QuestionAttempt>.Update.Set(value => value.BankCycle, 0));
|
||||||
|
if (!names.Contains(CycleIndexName))
|
||||||
|
await attempts.Indexes.CreateOneAsync(new CreateIndexModel<QuestionAttempt>(
|
||||||
|
Builders<QuestionAttempt>.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] : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,10 +17,11 @@ public class QuestionAttemptStore
|
|||||||
|
|
||||||
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
||||||
.Ascending(a => a.QuestionExternalId)
|
.Ascending(a => a.QuestionExternalId)
|
||||||
.Ascending(a => a.UserId);
|
.Ascending(a => a.UserId)
|
||||||
|
.Ascending(a => a.BankCycle);
|
||||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||||
firstAttemptIndex,
|
firstAttemptIndex,
|
||||||
new CreateIndexOptions { Unique = true }));
|
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
|
||||||
|
|
||||||
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
||||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||||
@@ -38,12 +39,14 @@ public class QuestionAttemptStore
|
|||||||
bool isCorrect,
|
bool isCorrect,
|
||||||
bool isSkip = false,
|
bool isSkip = false,
|
||||||
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||||
int durationSeconds = 0)
|
int durationSeconds = 0,
|
||||||
|
int bankCycle = 0)
|
||||||
{
|
{
|
||||||
var attempt = new QuestionAttempt
|
var attempt = new QuestionAttempt
|
||||||
{
|
{
|
||||||
QuestionExternalId = questionExternalId,
|
QuestionExternalId = questionExternalId,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
|
BankCycle = bankCycle,
|
||||||
ChoiceId = choiceId,
|
ChoiceId = choiceId,
|
||||||
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
|
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
|
||||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||||
@@ -63,7 +66,8 @@ public class QuestionAttemptStore
|
|||||||
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||||
{
|
{
|
||||||
var existing = await _col
|
var existing = await _col
|
||||||
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
|
.Find(a => a.QuestionExternalId == questionExternalId &&
|
||||||
|
a.UserId == userId && a.BankCycle == bankCycle)
|
||||||
.FirstAsync();
|
.FirstAsync();
|
||||||
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
|
||||||
}
|
}
|
||||||
@@ -75,11 +79,17 @@ public class QuestionAttemptStore
|
|||||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
|
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null)
|
||||||
|
{
|
||||||
|
var filter = Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId);
|
||||||
|
if (bankCycle.HasValue)
|
||||||
|
filter &= Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value);
|
||||||
|
return
|
||||||
_col.Distinct(
|
_col.Distinct(
|
||||||
attempt => attempt.QuestionExternalId,
|
attempt => attempt.QuestionExternalId,
|
||||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
|
filter)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public IMongoCollection<QuestionAttempt> Collection => _col;
|
public IMongoCollection<QuestionAttempt> Collection => _col;
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,23 @@ extends Node
|
|||||||
|
|
||||||
signal credits_changed
|
signal credits_changed
|
||||||
signal credits_failed(message: String)
|
signal credits_failed(message: String)
|
||||||
|
signal store_operation_completed(message: String)
|
||||||
|
|
||||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
|
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
|
||||||
|
|
||||||
var credits: int = 0
|
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 loaded := false
|
||||||
var loading := false
|
var loading := false
|
||||||
var request: HTTPRequest
|
var request: HTTPRequest
|
||||||
|
var action_request: HTTPRequest
|
||||||
var request_token := ""
|
var request_token := ""
|
||||||
var api_url := DEFAULT_URL
|
var api_url := DEFAULT_URL
|
||||||
|
var pending_store_action := ""
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -22,6 +29,10 @@ func _ready() -> void:
|
|||||||
request.timeout = 20.0
|
request.timeout = 20.0
|
||||||
request.request_completed.connect(_on_completed)
|
request.request_completed.connect(_on_completed)
|
||||||
add_child(request)
|
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)
|
AuthState.auth_changed.connect(_on_auth_changed)
|
||||||
|
|
||||||
|
|
||||||
@@ -39,6 +50,10 @@ func refresh() -> void:
|
|||||||
|
|
||||||
func apply_status(parsed: Dictionary) -> void:
|
func apply_status(parsed: Dictionary) -> void:
|
||||||
credits = int(parsed.get("credits", credits))
|
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
|
loaded = true
|
||||||
credits_changed.emit()
|
credits_changed.emit()
|
||||||
|
|
||||||
@@ -58,9 +73,61 @@ func _on_completed(result: int, response_code: int, _headers: PackedStringArray,
|
|||||||
apply_status(parsed)
|
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:
|
func _on_auth_changed() -> void:
|
||||||
if not AuthState.is_logged_in:
|
if not AuthState.is_logged_in:
|
||||||
credits = 0
|
credits = 0
|
||||||
|
exam_tokens = 0
|
||||||
|
qbank_reset_tokens = 0
|
||||||
|
bank_cycle = 0
|
||||||
|
products = []
|
||||||
loaded = false
|
loaded = false
|
||||||
loading = false
|
loading = false
|
||||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ signal state_changed
|
|||||||
signal request_failed(message: String)
|
signal request_failed(message: String)
|
||||||
signal outcome_changed
|
signal outcome_changed
|
||||||
signal history_changed
|
signal history_changed
|
||||||
|
signal exam_token_required
|
||||||
|
|
||||||
var api_url := DEFAULT_API_URL
|
var api_url := DEFAULT_API_URL
|
||||||
var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes"
|
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)
|
AuthState.handle_unauthorized(request_access_token)
|
||||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||||
var server_message := body.get_string_from_utf8().strip_edges()
|
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:
|
if server_message.length() > 240:
|
||||||
server_message = server_message.left(240)
|
server_message = server_message.left(240)
|
||||||
var message := "Simulation request failed with status %d." % response_code
|
var message := "Simulation request failed with status %d." % response_code
|
||||||
|
|||||||
@@ -130,6 +130,14 @@ func reset_qbank_memory() -> void:
|
|||||||
profile_changed.emit()
|
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:
|
func apply_question_results(results: Dictionary) -> void:
|
||||||
_ensure_daily_current()
|
_ensure_daily_current()
|
||||||
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
|
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
extends PanelContainer
|
extends PanelContainer
|
||||||
|
|
||||||
|
signal store_requested
|
||||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg")
|
const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg")
|
||||||
const STAR_ICON := preload("res://assets/icons/lucide/star.svg")
|
const STAR_ICON := preload("res://assets/icons/lucide/star.svg")
|
||||||
@@ -21,6 +22,9 @@ var _initialized := false
|
|||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
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()
|
_build_ui()
|
||||||
refresh_theme_controls()
|
refresh_theme_controls()
|
||||||
_on_credits_changed()
|
_on_credits_changed()
|
||||||
@@ -30,6 +34,11 @@ func _ready() -> void:
|
|||||||
resized.connect(func(): pivot_offset = size * 0.5)
|
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:
|
func _build_ui() -> void:
|
||||||
var margin := MarginContainer.new()
|
var margin := MarginContainer.new()
|
||||||
margin.add_theme_constant_override("margin_left", 7)
|
margin.add_theme_constant_override("margin_left", 7)
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ func _build_ui() -> void:
|
|||||||
header.add_child(account_chevron_button)
|
header.add_child(account_chevron_button)
|
||||||
|
|
||||||
credits_badge = CreditsBadge.new()
|
credits_badge = CreditsBadge.new()
|
||||||
|
credits_badge.store_requested.connect(func(): show_screen("rz_store"))
|
||||||
header.add_child(credits_badge)
|
header.add_child(credits_badge)
|
||||||
|
|
||||||
var screens_container := MarginContainer.new()
|
var screens_container := MarginContainer.new()
|
||||||
@@ -355,6 +356,11 @@ func _build_ui() -> void:
|
|||||||
donations.main = self
|
donations.main = self
|
||||||
screens_container.add_child(donations)
|
screens_container.add_child(donations)
|
||||||
screens["donations"] = 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 = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||||
feedback_dialog.name = "AppFeedbackDialog"
|
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, "flashcards", "Flashcards", "clipboard-plus", true, Brand.is_uat_environment())
|
||||||
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
|
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
|
||||||
_add_nav_button(container, "notes", "Notes", "notebook-pen", 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()
|
var spacer := Control.new()
|
||||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
@@ -476,6 +483,7 @@ func _on_navigation_pressed(key: String) -> void:
|
|||||||
"notes": show_screen("notes")
|
"notes": show_screen("notes")
|
||||||
"bookmarks": show_screen("bookmarks")
|
"bookmarks": show_screen("bookmarks")
|
||||||
"donations": show_screen("donations")
|
"donations": show_screen("donations")
|
||||||
|
"rz_store": show_screen("rz_store")
|
||||||
"logout": _logout()
|
"logout": _logout()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,4 +29,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
|||||||
key = "bookmarks"
|
key = "bookmarks"
|
||||||
elif screen_name == "donations":
|
elif screen_name == "donations":
|
||||||
key = "donations"
|
key = "donations"
|
||||||
|
elif screen_name == "rz_store":
|
||||||
|
key = "rz_store"
|
||||||
return key
|
return key
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cldpnvs2k6r3t
|
||||||
@@ -137,6 +137,8 @@ func _connect_services() -> void:
|
|||||||
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
|
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
|
||||||
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
|
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
|
||||||
ExamSimulationService.request_failed.connect(_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:
|
func on_show(_payload: Variant = null) -> void:
|
||||||
@@ -538,6 +540,11 @@ func _on_request_failed(message: String) -> void:
|
|||||||
_render_active_section()
|
_render_active_section()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_exam_token_required() -> void:
|
||||||
|
pending_launch = false
|
||||||
|
main.show_screen("rz_store")
|
||||||
|
|
||||||
|
|
||||||
func _update_responsive_layout() -> void:
|
func _update_responsive_layout() -> void:
|
||||||
var use_compact := size.x < 940
|
var use_compact := size.x < 940
|
||||||
if compact_layout == use_compact:
|
if compact_layout == use_compact:
|
||||||
|
|||||||
Reference in New Issue
Block a user