Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bbac27b84 |
@@ -9,6 +9,7 @@ set -euo pipefail
|
||||
: "${INTERNAL_PURGE_SECRET:?INTERNAL_PURGE_SECRET is required}"
|
||||
: "${STRIPE_SECRET_KEY:?STRIPE_SECRET_KEY is required}"
|
||||
: "${STRIPE_WEBHOOK_SECRET:?STRIPE_WEBHOOK_SECRET is required}"
|
||||
: "${STRIPE_ACCESS_WEBHOOK_SECRET:?STRIPE_ACCESS_WEBHOOK_SECRET is required}"
|
||||
|
||||
NODES="${NODES:-192.168.86.72 192.168.86.73 192.168.86.74}"
|
||||
BUILD_TAG="$(git rev-parse HEAD)"
|
||||
@@ -57,6 +58,7 @@ kubectl create secret generic step3-runtime-secrets \
|
||||
--from-literal=purge-secret="${INTERNAL_PURGE_SECRET}" \
|
||||
--from-literal=stripe-secret-key="${STRIPE_SECRET_KEY}" \
|
||||
--from-literal=stripe-webhook-secret="${STRIPE_WEBHOOK_SECRET}" \
|
||||
--from-literal=stripe-access-webhook-secret="${STRIPE_ACCESS_WEBHOOK_SECRET}" \
|
||||
--from-literal=google-client-id="${GOOGLE_CLIENT_ID:-}" \
|
||||
--from-literal=google-client-secret="${GOOGLE_CLIENT_SECRET:-}" \
|
||||
--dry-run=client -o yaml | kubectl apply -n "${KUBE_NAMESPACE}" -f -
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
INTERNAL_PURGE_SECRET: ${{ secrets.PRD_INTERNAL_PURGE_SECRET }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.PRD_STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.PRD_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_ACCESS_WEBHOOK_SECRET: ${{ secrets.PRD_STRIPE_ACCESS_WEBHOOK_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.PRD_GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.PRD_GOOGLE_CLIENT_SECRET }}
|
||||
run: bash .gitea/scripts/deploy-environment.sh
|
||||
|
||||
@@ -42,6 +42,7 @@ jobs:
|
||||
INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }}
|
||||
STRIPE_SECRET_KEY: ${{ secrets.UAT_STRIPE_SECRET_KEY }}
|
||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.UAT_STRIPE_WEBHOOK_SECRET }}
|
||||
STRIPE_ACCESS_WEBHOOK_SECRET: ${{ secrets.UAT_STRIPE_ACCESS_WEBHOOK_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.UAT_GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.UAT_GOOGLE_CLIENT_SECRET }}
|
||||
run: bash .gitea/scripts/deploy-environment.sh
|
||||
|
||||
+15
-4
@@ -18,10 +18,20 @@ The existing `KUBECONFIG` secret is shared. Configure these environment-specific
|
||||
- `UAT_MONGODB_CONNECTION_STRING` and `PRD_MONGODB_CONNECTION_STRING`
|
||||
- `UAT_AUTH_JWT_KEY` and `PRD_AUTH_JWT_KEY` (different values)
|
||||
- `UAT_INTERNAL_PURGE_SECRET` and `PRD_INTERNAL_PURGE_SECRET`
|
||||
- `UAT_STRIPE_SECRET_KEY` and `UAT_STRIPE_WEBHOOK_SECRET` (Stripe sandbox)
|
||||
- `PRD_STRIPE_SECRET_KEY` and `PRD_STRIPE_WEBHOOK_SECRET` (Stripe live mode)
|
||||
- `UAT_STRIPE_SECRET_KEY`, `UAT_STRIPE_WEBHOOK_SECRET`, and
|
||||
`UAT_STRIPE_ACCESS_WEBHOOK_SECRET` (Stripe sandbox)
|
||||
- `PRD_STRIPE_SECRET_KEY`, `PRD_STRIPE_WEBHOOK_SECRET`, and
|
||||
`PRD_STRIPE_ACCESS_WEBHOOK_SECRET` (Stripe live mode)
|
||||
|
||||
Use independent Stripe webhook endpoints for `https://us3q.ranaze.com/api/donations/stripe-webhook` and `https://s3q.ranaze.com/api/donations/stripe-webhook`. No Stripe publishable key is required for hosted Checkout.
|
||||
Configure four independent Stripe webhook endpoints:
|
||||
|
||||
- UAT donations: `https://us3q.ranaze.com/api/donations/stripe-webhook`
|
||||
- UAT access: `https://us3q.ranaze.com/api/access/stripe-webhook`
|
||||
- PRD donations: `https://s3q.ranaze.com/api/donations/stripe-webhook`
|
||||
- PRD access: `https://s3q.ranaze.com/api/access/stripe-webhook`
|
||||
|
||||
Each endpoint has its own signing secret. UAT uses Stripe test mode and PRD uses
|
||||
Stripe live mode. No Stripe publishable key is required for hosted Checkout.
|
||||
|
||||
## Initial UAT database migration
|
||||
|
||||
@@ -67,7 +77,8 @@ The hostname change creates a new browser origin, so testers should expect to si
|
||||
db.Users.updateOne({Username: "SUPER"}, {$set: {Role: "SUPER"}})
|
||||
```
|
||||
|
||||
7. Configure and test the live Stripe webhook and Steam callback before announcing PRD.
|
||||
7. Configure and test both live Stripe webhooks and the Steam callback before
|
||||
announcing PRD.
|
||||
|
||||
## Question promotion behavior
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ Environment deployment, database migration, and UAT-to-PRD promotion procedures
|
||||
|
||||
Godot-based USMLE Step 3 study trainer with a bundled ASP.NET Core Questions API.
|
||||
|
||||
The gated process for publishing the first Windows alpha through Steam is in
|
||||
[docs/STEAM_ALPHA_RELEASE_CHECKLIST.md](docs/STEAM_ALPHA_RELEASE_CHECKLIST.md).
|
||||
The free-access limits, earned-credit rules, paid tokens, and checkout setup are
|
||||
documented in [docs/ACCESS_ECONOMY.md](docs/ACCESS_ECONOMY.md).
|
||||
|
||||
## Local API
|
||||
|
||||
Run the Questions API:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using QuestionsApi.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class AccessCatalogTests
|
||||
{
|
||||
[Fact]
|
||||
public void FreeEconomy_HasTheReleaseLimits()
|
||||
{
|
||||
Assert.Equal(20, AccessCatalog.FreeQuestionLimit);
|
||||
Assert.Equal(20, AccessCatalog.CreditEarningLimit);
|
||||
Assert.Equal(100, AccessCatalog.DayPassCreditCost);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("access_day_1", 100, AccessGrant.DayToken)]
|
||||
[InlineData("access_week_1", 500, AccessGrant.WeekToken)]
|
||||
[InlineData("access_month_1", 2000, AccessGrant.MonthToken)]
|
||||
[InlineData("exam_token_1", 199, AccessGrant.ExamToken)]
|
||||
[InlineData("qbank_reset_1", 499, AccessGrant.ResetToken)]
|
||||
public void Products_HaveServerControlledPrices(string sku, long cents, string grant)
|
||||
{
|
||||
var product = AccessCatalog.Products[sku];
|
||||
|
||||
Assert.Equal(cents, product.AmountCents);
|
||||
Assert.Equal("usd", product.Currency);
|
||||
Assert.Equal(grant, product.Grant);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/access")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class AccessController(
|
||||
AccessStore access,
|
||||
ExamSimulationStore simulations,
|
||||
RatingStore ratings,
|
||||
TimeProvider clock) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get() =>
|
||||
Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
|
||||
|
||||
[HttpPost("redeem-credits")]
|
||||
public async Task<IActionResult> RedeemCredits(RedeemCreditsRequest request)
|
||||
{
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
var status = await access.RedeemCreditsAsync(UserId(), request.OperationId.Trim());
|
||||
return status is null
|
||||
? Conflict(new { code = "insufficient_credits", message = "100 credits are required to redeem a day pass." })
|
||||
: Ok(status);
|
||||
}
|
||||
|
||||
[HttpPost("activate-token")]
|
||||
public async Task<IActionResult> ActivateToken(ActivateAccessTokenRequest request)
|
||||
{
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
if (request.Token.Trim().ToLowerInvariant() is not ("day" or "week" or "month"))
|
||||
return BadRequest("token must be day, week, or month");
|
||||
var status = await access.ActivateTokenAsync(UserId(), request.OperationId.Trim(), request.Token);
|
||||
return status is null
|
||||
? Conflict(new { code = "token_unavailable", message = "That access token is not available." })
|
||||
: Ok(status);
|
||||
}
|
||||
|
||||
[HttpPost("qbank-reset")]
|
||||
public async Task<IActionResult> ResetQBank(ResetQBankRequest request)
|
||||
{
|
||||
if (request.Confirmation != "RESET_QBANK") return BadRequest("Type RESET_QBANK to confirm.");
|
||||
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
|
||||
if (await simulations.FindActiveAsync(UserId()) is not null)
|
||||
return Conflict(new { code = "active_exam", message = "Finish or abandon the active exam simulation before resetting the QBank." });
|
||||
var cycle = await access.ApplyResetAsync(UserId(), request.OperationId.Trim());
|
||||
if (cycle is null)
|
||||
return Conflict(new { code = "reset_token_unavailable", message = "A QBank reset token is required." });
|
||||
await ratings.ResetUserAsync(UserId(), clock.GetUtcNow().UtcDateTime);
|
||||
return Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
|
||||
}
|
||||
|
||||
[HttpPost("steam-checkouts")]
|
||||
public IActionResult SteamCheckout() => Problem(
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "Steam Wallet checkout is not configured",
|
||||
detail: "Steam purchases will be enabled after the Steamworks application and product catalog are configured.");
|
||||
|
||||
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.Trim().Length <= 100;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/access")]
|
||||
public sealed class AccessPurchasesController(
|
||||
AccessPurchaseStore purchases,
|
||||
AccessStore access,
|
||||
IStripeAccessGateway stripe,
|
||||
IConfiguration configuration,
|
||||
ILogger<AccessPurchasesController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("web-checkouts")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> CreateCheckout(CreateAccessCheckoutRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!AccessCatalog.Products.TryGetValue(request.Sku.Trim(), out var product))
|
||||
return BadRequest("Unknown access product.");
|
||||
var purchase = await purchases.CreateAsync(UserId(), product);
|
||||
try
|
||||
{
|
||||
var checkout = await stripe.CreateCheckoutAsync(purchase, product, cancellationToken);
|
||||
await purchases.AttachSessionAsync(purchase.Id!, checkout.SessionId);
|
||||
return StatusCode(StatusCodes.Status201Created, new AccessCheckoutResponse
|
||||
{
|
||||
PurchaseId = purchase.Id!,
|
||||
CheckoutUrl = checkout.CheckoutUrl,
|
||||
Sku = product.Sku,
|
||||
AmountCents = product.AmountCents
|
||||
});
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await purchases.MarkCheckoutFailedAsync(purchase.Id!);
|
||||
logger.LogError(exception, "Unable to create access checkout {PurchaseId}", purchase.Id);
|
||||
return Problem(statusCode: StatusCodes.Status502BadGateway,
|
||||
title: "Access checkout unavailable",
|
||||
detail: "The secure checkout could not be started. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("purchases/{id}")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> GetPurchase(string id) =>
|
||||
await purchases.GetOwnedAsync(id.Trim(), UserId()) is { } purchase
|
||||
? Ok(AccessPurchaseStore.ToStatus(purchase))
|
||||
: NotFound();
|
||||
|
||||
[HttpPost("stripe-webhook")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> StripeWebhook(CancellationToken cancellationToken)
|
||||
{
|
||||
var webhookSecret = configuration["Stripe:AccessWebhookSecret"];
|
||||
if (string.IsNullOrWhiteSpace(webhookSecret))
|
||||
return Problem(statusCode: 503, title: "Stripe webhook is not configured");
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var json = await reader.ReadToEndAsync(cancellationToken);
|
||||
Event stripeEvent;
|
||||
try
|
||||
{
|
||||
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], webhookSecret);
|
||||
}
|
||||
catch (StripeException exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Rejected access webhook with an invalid signature");
|
||||
return BadRequest("Invalid Stripe signature");
|
||||
}
|
||||
if (stripeEvent.Data.Object is not Session session) return Ok();
|
||||
var status = stripeEvent.Type switch
|
||||
{
|
||||
"checkout.session.completed" when session.PaymentStatus == "paid" => AccessPurchaseStatus.Paid,
|
||||
"checkout.session.async_payment_succeeded" => AccessPurchaseStatus.Paid,
|
||||
"checkout.session.async_payment_failed" => AccessPurchaseStatus.Failed,
|
||||
"checkout.session.expired" => AccessPurchaseStatus.Expired,
|
||||
_ => ""
|
||||
};
|
||||
if (status.Length == 0) return Ok();
|
||||
var purchaseId = session.ClientReferenceId;
|
||||
if (string.IsNullOrWhiteSpace(purchaseId) &&
|
||||
session.Metadata.TryGetValue("access_purchase_id", out var metadataId))
|
||||
purchaseId = metadataId;
|
||||
var purchase = await purchases.ApplyProviderStatusAsync(
|
||||
purchaseId ?? "", session.Id, session.AmountTotal ?? 0, session.Currency ?? "",
|
||||
status, session.PaymentIntentId ?? "", stripeEvent.Id);
|
||||
if (purchase is null) return Ok();
|
||||
if (status == AccessPurchaseStatus.Paid &&
|
||||
AccessCatalog.Products.TryGetValue(purchase.Sku, out var product))
|
||||
{
|
||||
await access.ApplyProductGrantAsync(purchase.UserId, product, $"purchase:{purchase.Id}");
|
||||
await purchases.MarkGrantAppliedAsync(purchase.Id!);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name)
|
||||
?? User.Identity?.Name
|
||||
?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -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, AccessStore access) : ControllerBase
|
||||
{
|
||||
[HttpGet("readiness")]
|
||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||
@@ -17,12 +17,28 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var operationId = $"exam-create:{Guid.NewGuid():N}";
|
||||
if (!User.IsInRole("SUPER") && !await access.TryConsumeExamTokenAsync(UserId(), operationId))
|
||||
return StatusCode(StatusCodes.Status402PaymentRequired, new
|
||||
{
|
||||
code = "exam_token_required",
|
||||
message = "An exam simulation token is required."
|
||||
});
|
||||
try
|
||||
{
|
||||
var simulation = await simulations.CreateAsync(UserId());
|
||||
return Ok(await simulations.ToResponseAsync(simulation));
|
||||
}
|
||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
|
||||
return Conflict(exception.Message);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("preview-results")]
|
||||
|
||||
@@ -19,6 +19,7 @@ public class QuestionsController : ControllerBase
|
||||
private readonly ServiceMetrics _metrics;
|
||||
private readonly QuestionBookmarkStore _bookmarks;
|
||||
private readonly ReviewQueueStore _reviewQueue;
|
||||
private readonly AccessStore _access;
|
||||
|
||||
public QuestionsController(
|
||||
QuestionStore questions,
|
||||
@@ -28,7 +29,8 @@ public class QuestionsController : ControllerBase
|
||||
AnswerStatisticsStore statistics,
|
||||
ServiceMetrics metrics,
|
||||
QuestionBookmarkStore bookmarks,
|
||||
ReviewQueueStore reviewQueue)
|
||||
ReviewQueueStore reviewQueue,
|
||||
AccessStore access)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -38,6 +40,7 @@ public class QuestionsController : ControllerBase
|
||||
_metrics = metrics;
|
||||
_bookmarks = bookmarks;
|
||||
_reviewQueue = reviewQueue;
|
||||
_access = access;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -52,6 +55,13 @@ public class QuestionsController : ControllerBase
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
|
||||
{
|
||||
if (!await _access.CanLoadQuestionAsync(UserId(), User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted. Redeem or activate an access pass to continue.",
|
||||
access = await _access.GetStatusAsync(UserId())
|
||||
});
|
||||
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
|
||||
var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
|
||||
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
|
||||
@@ -117,6 +127,8 @@ public class QuestionsController : ControllerBase
|
||||
return BadRequest("questionExternalId required");
|
||||
if (string.IsNullOrWhiteSpace(req.ChoiceId))
|
||||
return BadRequest("choiceId required");
|
||||
if (string.IsNullOrWhiteSpace(req.CompletionId) || req.CompletionId.Trim().Length > 100)
|
||||
return BadRequest("completionId required and must be 100 characters or fewer");
|
||||
|
||||
var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
|
||||
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
|
||||
@@ -146,6 +158,15 @@ public class QuestionsController : ControllerBase
|
||||
?? "";
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
var completionId = req.CompletionId.Trim();
|
||||
if (!await _access.ClaimQuestionAsync(userId, completionId, question.ExternalId, User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted.",
|
||||
access = await _access.GetStatusAsync(userId)
|
||||
});
|
||||
var bankCycle = await _access.GetBankCycleAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
@@ -154,7 +175,9 @@ public class QuestionsController : ControllerBase
|
||||
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
||||
false,
|
||||
presentedChoiceIds,
|
||||
req.DurationSeconds);
|
||||
req.DurationSeconds,
|
||||
bankCycle,
|
||||
completionId);
|
||||
if (record.Inserted)
|
||||
{
|
||||
_metrics.AttemptCreated();
|
||||
@@ -172,6 +195,8 @@ public class QuestionsController : ControllerBase
|
||||
_metrics.AttemptDuplicate();
|
||||
}
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
if (record.Inserted && !User.IsInRole("SUPER"))
|
||||
await _access.TryAwardCreditAsync(userId, $"qbank-credit:{record.Attempt.Id}");
|
||||
|
||||
var ratingOwner = Guid.NewGuid().ToString("N");
|
||||
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
|
||||
@@ -204,7 +229,8 @@ public class QuestionsController : ControllerBase
|
||||
question,
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating
|
||||
Rating = rating,
|
||||
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,7 +278,7 @@ public class QuestionsController : ControllerBase
|
||||
|
||||
[HttpPost("{externalId}/skip")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public async Task<IActionResult> Skip(string externalId)
|
||||
public async Task<IActionResult> Skip(string externalId, [FromQuery] string completionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(externalId))
|
||||
return BadRequest("externalId required");
|
||||
@@ -264,13 +290,28 @@ public class QuestionsController : ControllerBase
|
||||
var userId = CurrentUserId();
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return Unauthorized();
|
||||
if (string.IsNullOrWhiteSpace(completionId) || completionId.Trim().Length > 100)
|
||||
return BadRequest("completionId required and must be 100 characters or fewer");
|
||||
completionId = completionId.Trim();
|
||||
if (!await _access.ClaimQuestionAsync(userId, completionId, question.ExternalId, User.IsInRole("SUPER")))
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
code = "qbank_limit_reached",
|
||||
message = "Your free QBank allowance is exhausted.",
|
||||
access = await _access.GetStatusAsync(userId)
|
||||
});
|
||||
var bankCycle = await _access.GetBankCycleAsync(userId);
|
||||
|
||||
var record = await _attempts.RecordFirstAttemptAsync(
|
||||
question.ExternalId,
|
||||
userId,
|
||||
"",
|
||||
false,
|
||||
true);
|
||||
true,
|
||||
null,
|
||||
0,
|
||||
bankCycle,
|
||||
completionId);
|
||||
if (record.Inserted)
|
||||
_metrics.AttemptCreated();
|
||||
else
|
||||
@@ -304,7 +345,8 @@ public class QuestionsController : ControllerBase
|
||||
question,
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating
|
||||
Rating = rating,
|
||||
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class AccessCatalog
|
||||
{
|
||||
public const int FreeQuestionLimit = 20;
|
||||
public const int CreditEarningLimit = 20;
|
||||
public const int DayPassCreditCost = 100;
|
||||
|
||||
public static readonly IReadOnlyDictionary<string, AccessProduct> Products =
|
||||
new Dictionary<string, AccessProduct>(StringComparer.Ordinal)
|
||||
{
|
||||
["access_day_1"] = new("access_day_1", "One-day QBank token", 100, "usd", AccessGrant.DayToken),
|
||||
["access_week_1"] = new("access_week_1", "Seven-day QBank token", 500, "usd", AccessGrant.WeekToken),
|
||||
["access_month_1"] = new("access_month_1", "Thirty-day QBank token", 2000, "usd", AccessGrant.MonthToken),
|
||||
["exam_token_1"] = new("exam_token_1", "Exam simulation token", 199, "usd", AccessGrant.ExamToken),
|
||||
["qbank_reset_1"] = new("qbank_reset_1", "QBank reset token", 499, "usd", AccessGrant.ResetToken)
|
||||
};
|
||||
}
|
||||
|
||||
public static class AccessGrant
|
||||
{
|
||||
public const string DayToken = "day_token";
|
||||
public const string WeekToken = "week_token";
|
||||
public const string MonthToken = "month_token";
|
||||
public const string ExamToken = "exam_token";
|
||||
public const string ResetToken = "reset_token";
|
||||
}
|
||||
|
||||
public sealed record AccessProduct(string Sku, string Name, long AmountCents, string Currency, string Grant);
|
||||
|
||||
public sealed class AccessEvent
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string SubjectId { get; set; } = "";
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class UserAccessAccount
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public int Credits { get; set; }
|
||||
public int DayTokens { get; set; }
|
||||
public int WeekTokens { get; set; }
|
||||
public int MonthTokens { get; set; }
|
||||
public int ExamTokens { get; set; }
|
||||
public int ResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public DateTime? UnlimitedUntilUtc { get; set; }
|
||||
public List<AccessEvent> RecentUsage { get; set; } = [];
|
||||
public List<AccessEvent> RecentCredits { get; set; } = [];
|
||||
public List<string> AppliedOperationIds { get; set; } = [];
|
||||
public int Revision { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class AccessStatusResponse
|
||||
{
|
||||
public int FreeQuestionLimit { get; set; } = AccessCatalog.FreeQuestionLimit;
|
||||
public int QuestionsUsed { get; set; }
|
||||
public int QuestionsRemaining { get; set; }
|
||||
public DateTime? NextQuestionAvailableUtc { get; set; }
|
||||
public int Credits { get; set; }
|
||||
public int DayPassCreditCost { get; set; } = AccessCatalog.DayPassCreditCost;
|
||||
public int DayTokens { get; set; }
|
||||
public int WeekTokens { get; set; }
|
||||
public int MonthTokens { get; set; }
|
||||
public int ExamTokens { get; set; }
|
||||
public int ResetTokens { get; set; }
|
||||
public int BankCycle { get; set; }
|
||||
public bool Unlimited { get; set; }
|
||||
public DateTime? UnlimitedUntilUtc { get; set; }
|
||||
public List<AccessProduct> Products { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class ActivateAccessTokenRequest
|
||||
{
|
||||
public string Token { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class RedeemCreditsRequest
|
||||
{
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class ResetQBankRequest
|
||||
{
|
||||
public string Confirmation { get; set; } = "";
|
||||
public string OperationId { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class CreateAccessCheckoutRequest
|
||||
{
|
||||
public string Sku { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class AccessCheckoutResponse
|
||||
{
|
||||
public string PurchaseId { get; set; } = "";
|
||||
public string CheckoutUrl { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
}
|
||||
|
||||
public static class AccessPurchaseStatus
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Paid = "paid";
|
||||
public const string Failed = "failed";
|
||||
public const string Expired = "expired";
|
||||
public const string Refunded = "refunded";
|
||||
}
|
||||
|
||||
public sealed class AccessPurchase
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
public string? Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "usd";
|
||||
public string Status { get; set; } = AccessPurchaseStatus.Pending;
|
||||
public string Provider { get; set; } = "stripe";
|
||||
public string ProviderSessionId { get; set; } = "";
|
||||
public string ProviderPaymentId { get; set; } = "";
|
||||
public List<string> ProviderEventIds { get; set; } = [];
|
||||
public bool GrantApplied { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class AccessPurchaseStatusResponse
|
||||
{
|
||||
public string PurchaseId { get; set; } = "";
|
||||
public string Sku { get; set; } = "";
|
||||
public long AmountCents { get; set; }
|
||||
public string Currency { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
}
|
||||
@@ -11,6 +11,8 @@ public class QuestionAttempt
|
||||
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public int BankCycle { get; set; }
|
||||
public string CompletionId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
public bool TracksPresentation { get; set; }
|
||||
|
||||
@@ -45,6 +45,7 @@ public class FlagQuestionRequest
|
||||
|
||||
public class FirstQuestionAttemptRequest
|
||||
{
|
||||
public string CompletionId { get; set; } = "";
|
||||
public string QuestionExternalId { get; set; } = "";
|
||||
public string ChoiceId { get; set; } = "";
|
||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||
|
||||
@@ -141,6 +141,7 @@ public class FirstQuestionAttemptResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public AccessStatusResponse? Access { get; set; }
|
||||
}
|
||||
|
||||
public class SkipQuestionResponse
|
||||
@@ -150,6 +151,7 @@ public class SkipQuestionResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
public int QuestionElo { get; set; } = 1000;
|
||||
public RatingChangeResponse? Rating { get; set; }
|
||||
public AccessStatusResponse? Access { get; set; }
|
||||
}
|
||||
|
||||
public static class RatingCategory
|
||||
|
||||
@@ -66,6 +66,10 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<MongoDatabaseProvider>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<AccessStore>();
|
||||
builder.Services.AddSingleton<AccessPurchaseStore>();
|
||||
builder.Services.AddSingleton<IStripeAccessGateway, StripeAccessGateway>();
|
||||
builder.Services.AddSingleton<QuestionStore>();
|
||||
builder.Services.AddSingleton<QuestionAttemptStore>();
|
||||
builder.Services.AddSingleton<AnswerStatisticsStore>();
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AccessPurchaseStore
|
||||
{
|
||||
private readonly IMongoCollection<AccessPurchase> _purchases;
|
||||
|
||||
public AccessPurchaseStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
_purchases = mongo.Database.GetCollection<AccessPurchase>("AccessPurchases");
|
||||
_purchases.Indexes.CreateMany([
|
||||
new CreateIndexModel<AccessPurchase>(
|
||||
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)),
|
||||
new CreateIndexModel<AccessPurchase>(
|
||||
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.ProviderSessionId),
|
||||
new CreateIndexOptions<AccessPurchase>
|
||||
{
|
||||
Unique = true,
|
||||
PartialFilterExpression = Builders<AccessPurchase>.Filter.Ne(value => value.ProviderSessionId, "")
|
||||
})
|
||||
]);
|
||||
}
|
||||
|
||||
public async Task<AccessPurchase> CreateAsync(string userId, AccessProduct product)
|
||||
{
|
||||
var purchase = new AccessPurchase
|
||||
{
|
||||
UserId = userId,
|
||||
Sku = product.Sku,
|
||||
AmountCents = product.AmountCents,
|
||||
Currency = product.Currency
|
||||
};
|
||||
await _purchases.InsertOneAsync(purchase);
|
||||
return purchase;
|
||||
}
|
||||
|
||||
public async Task<AccessPurchase?> GetOwnedAsync(string id, string userId) =>
|
||||
await _purchases.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<AccessPurchase?> GetAsync(string id) =>
|
||||
await _purchases.Find(value => value.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public Task AttachSessionAsync(string id, string sessionId) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.ProviderSessionId, sessionId)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public Task MarkCheckoutFailedAsync(string id) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.Status, AccessPurchaseStatus.Failed)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public async Task<AccessPurchase?> ApplyProviderStatusAsync(
|
||||
string id, string sessionId, long amountCents, string currency,
|
||||
string status, string paymentId, string eventId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(sessionId)) return null;
|
||||
var filters = Builders<AccessPurchase>.Filter;
|
||||
var filter = filters.Eq(value => value.Id, id) &
|
||||
filters.Eq(value => value.AmountCents, amountCents) &
|
||||
filters.Eq(value => value.Currency, currency.ToLowerInvariant()) &
|
||||
(filters.Eq(value => value.ProviderSessionId, sessionId) |
|
||||
filters.Eq(value => value.ProviderSessionId, "")) &
|
||||
filters.Not(filters.AnyEq(value => value.ProviderEventIds, eventId));
|
||||
var update = Builders<AccessPurchase>.Update
|
||||
.Set(value => value.Status, status)
|
||||
.Set(value => value.ProviderSessionId, sessionId)
|
||||
.Set(value => value.ProviderPaymentId, paymentId)
|
||||
.AddToSet(value => value.ProviderEventIds, eventId)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow);
|
||||
return await _purchases.FindOneAndUpdateAsync(filter, update,
|
||||
new FindOneAndUpdateOptions<AccessPurchase> { ReturnDocument = ReturnDocument.After });
|
||||
}
|
||||
|
||||
public Task MarkGrantAppliedAsync(string id) =>
|
||||
_purchases.UpdateOneAsync(
|
||||
value => value.Id == id && value.Status == AccessPurchaseStatus.Paid,
|
||||
Builders<AccessPurchase>.Update
|
||||
.Set(value => value.GrantApplied, true)
|
||||
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
|
||||
|
||||
public static AccessPurchaseStatusResponse ToStatus(AccessPurchase purchase) => new()
|
||||
{
|
||||
PurchaseId = purchase.Id ?? "",
|
||||
Sku = purchase.Sku,
|
||||
AmountCents = purchase.AmountCents,
|
||||
Currency = purchase.Currency,
|
||||
Status = purchase.Status
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class AccessStore
|
||||
{
|
||||
private readonly IMongoCollection<UserAccessAccount> _accounts;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public AccessStore(MongoDatabaseProvider mongo, TimeProvider time)
|
||||
{
|
||||
_accounts = mongo.Database.GetCollection<UserAccessAccount>("UserAccessAccounts");
|
||||
_time = time;
|
||||
_accounts.Indexes.CreateOne(new CreateIndexModel<UserAccessAccount>(
|
||||
Builders<UserAccessAccount>.IndexKeys.Ascending(value => value.UserId),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_access_user" }));
|
||||
}
|
||||
|
||||
public async Task<AccessStatusResponse> GetStatusAsync(string userId, bool bypass = false)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
return ToStatus(account, now, bypass);
|
||||
}
|
||||
|
||||
public async Task<int> GetBankCycleAsync(string userId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
|
||||
}
|
||||
|
||||
public async Task<bool> CanLoadQuestionAsync(string userId, bool bypass)
|
||||
{
|
||||
if (bypass) return true;
|
||||
var status = await GetStatusAsync(userId);
|
||||
return status.Unlimited || status.QuestionsRemaining > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> ClaimQuestionAsync(string userId, string completionId, string questionExternalId, bool bypass)
|
||||
{
|
||||
if (bypass) return true;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var exactClaim = filters.ElemMatch(value => value.RecentUsage,
|
||||
item => item.Id == completionId && item.SubjectId == questionExternalId);
|
||||
if (await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync())
|
||||
return true;
|
||||
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
|
||||
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
|
||||
{
|
||||
new MongoDB.Bson.BsonDocument("$size", "$RecentUsage"),
|
||||
AccessCatalog.FreeQuestionLimit
|
||||
})));
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.ElemMatch(value => value.RecentUsage, item => item.Id == completionId)) &
|
||||
(filters.Gt(value => value.UnlimitedUntilUtc, now) |
|
||||
capacity);
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.AddToSet(value => value.RecentUsage, new AccessEvent
|
||||
{
|
||||
Id = completionId,
|
||||
SubjectId = questionExternalId,
|
||||
CreatedUtc = now
|
||||
})
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
if ((await _accounts.UpdateOneAsync(filter, update)).MatchedCount == 1)
|
||||
return true;
|
||||
return await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync();
|
||||
}
|
||||
|
||||
public async Task<bool> TryAwardCreditAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
|
||||
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
|
||||
{
|
||||
new MongoDB.Bson.BsonDocument("$size", "$RecentCredits"),
|
||||
AccessCatalog.CreditEarningLimit
|
||||
})));
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)) &
|
||||
capacity;
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.Credits, 1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Push(value => value.RecentCredits, new AccessEvent { Id = operationId, CreatedUtc = now })
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public Task<AccessStatusResponse?> RedeemCreditsAsync(string userId, string operationId) =>
|
||||
SpendAndExtendAsync(userId, operationId, AccessCatalog.DayPassCreditCost, null, TimeSpan.FromHours(24));
|
||||
|
||||
public Task<AccessStatusResponse?> ActivateTokenAsync(string userId, string operationId, string token) =>
|
||||
token.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"day" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.DayTokens), TimeSpan.FromHours(24)),
|
||||
"week" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.WeekTokens), TimeSpan.FromHours(168)),
|
||||
"month" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.MonthTokens), TimeSpan.FromHours(720)),
|
||||
_ => Task.FromResult<AccessStatusResponse?>(null)
|
||||
};
|
||||
|
||||
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Gt(value => value.ExamTokens, 0) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ExamTokens, -1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public async Task RefundExamTokenAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var filter = Builders<UserAccessAccount>.Filter.Eq(value => value.UserId, userId) &
|
||||
Builders<UserAccessAccount>.Filter.AnyEq(value => value.AppliedOperationIds, operationId);
|
||||
await _accounts.UpdateOneAsync(filter, Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ExamTokens, 1)
|
||||
.Pull(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now));
|
||||
}
|
||||
|
||||
public async Task<int?> ApplyResetAsync(string userId, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Gt(value => value.ResetTokens, 0) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var updated = await _accounts.FindOneAndUpdateAsync(
|
||||
filter,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.Inc(value => value.ResetTokens, -1)
|
||||
.Inc(value => value.BankCycle, 1)
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1),
|
||||
new FindOneAndUpdateOptions<UserAccessAccount> { ReturnDocument = ReturnDocument.After });
|
||||
return updated?.BankCycle;
|
||||
}
|
||||
|
||||
public async Task<bool> ApplyProductGrantAsync(string userId, AccessProduct product, string operationId)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
update = product.Grant switch
|
||||
{
|
||||
AccessGrant.DayToken => update.Inc(value => value.DayTokens, 1),
|
||||
AccessGrant.WeekToken => update.Inc(value => value.WeekTokens, 1),
|
||||
AccessGrant.MonthToken => update.Inc(value => value.MonthTokens, 1),
|
||||
AccessGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
|
||||
AccessGrant.ResetToken => update.Inc(value => value.ResetTokens, 1),
|
||||
_ => update
|
||||
};
|
||||
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
private async Task<AccessStatusResponse?> SpendAndExtendAsync(
|
||||
string userId, string operationId, int credits, string? tokenProperty, TimeSpan duration)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(operationId)) return null;
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var filters = Builders<UserAccessAccount>.Filter;
|
||||
var filter = filters.Eq(value => value.UserId, userId) &
|
||||
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
|
||||
if (credits > 0) filter &= filters.Gte(value => value.Credits, credits);
|
||||
filter &= tokenProperty switch
|
||||
{
|
||||
nameof(UserAccessAccount.DayTokens) => filters.Gt(value => value.DayTokens, 0),
|
||||
nameof(UserAccessAccount.WeekTokens) => filters.Gt(value => value.WeekTokens, 0),
|
||||
nameof(UserAccessAccount.MonthTokens) => filters.Gt(value => value.MonthTokens, 0),
|
||||
_ => FilterDefinition<UserAccessAccount>.Empty
|
||||
};
|
||||
var account = await _accounts.Find(filter).FirstOrDefaultAsync();
|
||||
if (account is null) return null;
|
||||
var start = account.UnlimitedUntilUtc is { } expiry && expiry > now ? expiry : now;
|
||||
var update = Builders<UserAccessAccount>.Update
|
||||
.Set(value => value.UnlimitedUntilUtc, start.Add(duration))
|
||||
.AddToSet(value => value.AppliedOperationIds, operationId)
|
||||
.Set(value => value.UpdatedUtc, now)
|
||||
.Inc(value => value.Revision, 1);
|
||||
if (credits > 0) update = update.Inc(value => value.Credits, -credits);
|
||||
update = tokenProperty switch
|
||||
{
|
||||
nameof(UserAccessAccount.DayTokens) => update.Inc(value => value.DayTokens, -1),
|
||||
nameof(UserAccessAccount.WeekTokens) => update.Inc(value => value.WeekTokens, -1),
|
||||
nameof(UserAccessAccount.MonthTokens) => update.Inc(value => value.MonthTokens, -1),
|
||||
_ => update
|
||||
};
|
||||
var applied = await _accounts.UpdateOneAsync(
|
||||
filter & filters.Eq(value => value.Revision, account.Revision), update);
|
||||
return applied.ModifiedCount == 1 ? await GetStatusAsync(userId) : null;
|
||||
}
|
||||
|
||||
private async Task EnsureAndCleanAsync(string userId, DateTime now)
|
||||
{
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.SetOnInsert(value => value.UserId, userId)
|
||||
.SetOnInsert(value => value.UpdatedUtc, now),
|
||||
new UpdateOptions { IsUpsert = true });
|
||||
var cutoff = now.AddHours(-24);
|
||||
await _accounts.UpdateOneAsync(
|
||||
value => value.UserId == userId,
|
||||
Builders<UserAccessAccount>.Update
|
||||
.PullFilter(value => value.RecentUsage, item => item.CreatedUtc <= cutoff)
|
||||
.PullFilter(value => value.RecentCredits, item => item.CreatedUtc <= cutoff));
|
||||
}
|
||||
|
||||
private static AccessStatusResponse ToStatus(UserAccessAccount account, DateTime now, bool bypass)
|
||||
{
|
||||
var unlimited = bypass || account.UnlimitedUntilUtc > now;
|
||||
var used = account.RecentUsage.Count;
|
||||
return new AccessStatusResponse
|
||||
{
|
||||
QuestionsUsed = used,
|
||||
QuestionsRemaining = unlimited ? AccessCatalog.FreeQuestionLimit : Math.Max(0, AccessCatalog.FreeQuestionLimit - used),
|
||||
NextQuestionAvailableUtc = !unlimited && used >= AccessCatalog.FreeQuestionLimit
|
||||
? account.RecentUsage.MinBy(value => value.CreatedUtc)?.CreatedUtc.AddHours(24)
|
||||
: null,
|
||||
Credits = account.Credits,
|
||||
DayTokens = account.DayTokens,
|
||||
WeekTokens = account.WeekTokens,
|
||||
MonthTokens = account.MonthTokens,
|
||||
ExamTokens = account.ExamTokens,
|
||||
ResetTokens = account.ResetTokens,
|
||||
BankCycle = account.BankCycle,
|
||||
Unlimited = unlimited,
|
||||
UnlimitedUntilUtc = account.UnlimitedUntilUtc,
|
||||
Products = AccessCatalog.Products.Values.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public sealed class DashboardStore
|
||||
private readonly FlashcardScheduleStore _flashcardSchedules;
|
||||
private readonly RatingStore _ratingStore;
|
||||
private readonly CcsRatingStore _ccsRatingStore;
|
||||
private readonly AccessStore _accessStore;
|
||||
private readonly IMongoCollection<UserRating> _ratings;
|
||||
|
||||
public static readonly string[] Step3Systems =
|
||||
@@ -48,7 +49,8 @@ public sealed class DashboardStore
|
||||
FlashcardStudyService flashcards,
|
||||
FlashcardScheduleStore flashcardSchedules,
|
||||
RatingStore ratingStore,
|
||||
CcsRatingStore ccsRatingStore)
|
||||
CcsRatingStore ccsRatingStore,
|
||||
AccessStore accessStore)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||
@@ -65,6 +67,7 @@ public sealed class DashboardStore
|
||||
_flashcardSchedules = flashcardSchedules;
|
||||
_ratingStore = ratingStore;
|
||||
_ccsRatingStore = ccsRatingStore;
|
||||
_accessStore = accessStore;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
@@ -72,7 +75,9 @@ public sealed class DashboardStore
|
||||
var today = nowUtc.Date;
|
||||
var trendStart = today.AddDays(-29);
|
||||
var weekStart = today.AddDays(-6);
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
var bankCycleTask = _accessStore.GetBankCycleAsync(userId);
|
||||
var bankCycle = await bankCycleTask;
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId && value.BankCycle == bankCycle).ToListAsync();
|
||||
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
|
||||
@@ -15,12 +15,20 @@ public class QuestionAttemptStore
|
||||
{
|
||||
_col = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
|
||||
try
|
||||
{
|
||||
_col.Indexes.DropOne("QuestionExternalId_1_UserId_1");
|
||||
}
|
||||
catch (MongoCommandException exception) when (exception.Code is 26 or 27)
|
||||
{
|
||||
}
|
||||
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
|
||||
.Ascending(a => a.QuestionExternalId)
|
||||
.Ascending(a => a.UserId);
|
||||
.Ascending(a => a.UserId)
|
||||
.Ascending(a => a.BankCycle);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||
firstAttemptIndex,
|
||||
new CreateIndexOptions { Unique = true }));
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
|
||||
|
||||
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
|
||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
|
||||
@@ -38,12 +46,16 @@ public class QuestionAttemptStore
|
||||
bool isCorrect,
|
||||
bool isSkip = false,
|
||||
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||
int durationSeconds = 0)
|
||||
int durationSeconds = 0,
|
||||
int bankCycle = 0,
|
||||
string completionId = "")
|
||||
{
|
||||
var attempt = new QuestionAttempt
|
||||
{
|
||||
QuestionExternalId = questionExternalId,
|
||||
UserId = userId,
|
||||
BankCycle = bankCycle,
|
||||
CompletionId = completionId,
|
||||
ChoiceId = choiceId,
|
||||
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
|
||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||
@@ -63,7 +75,7 @@ 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,10 +87,13 @@ public class QuestionAttemptStore
|
||||
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
|
||||
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) =>
|
||||
_col.Distinct(
|
||||
attempt => attempt.QuestionExternalId,
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
|
||||
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId) &
|
||||
(bankCycle is null
|
||||
? FilterDefinition<QuestionAttempt>.Empty
|
||||
: Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value)))
|
||||
.ToListAsync();
|
||||
|
||||
public IMongoCollection<QuestionAttempt> Collection => _col;
|
||||
|
||||
@@ -41,6 +41,18 @@ public class RatingStore
|
||||
return await EnsureHistoryBaselineAsync(rating, nowUtc);
|
||||
}
|
||||
|
||||
public Task ResetUserAsync(string userId, DateTime nowUtc) =>
|
||||
_users.UpdateOneAsync(
|
||||
rating => rating.UserId == userId,
|
||||
Builders<UserRating>.Update
|
||||
.Set(rating => rating.OverallElo, BaseElo)
|
||||
.Set(rating => rating.RatedQuestionCount, 0)
|
||||
.Set(rating => rating.CategoryElos, DefaultCategoryElos())
|
||||
.Set(rating => rating.HistoryBaselineElo, null)
|
||||
.Set(rating => rating.HistoryStartedUtc, null)
|
||||
.Set(rating => rating.UpdatedUtc, nowUtc),
|
||||
new UpdateOptions { IsUpsert = false });
|
||||
|
||||
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
|
||||
{
|
||||
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using QuestionsApi.Models;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public interface IStripeAccessGateway
|
||||
{
|
||||
Task<StripeCheckoutResult> CreateCheckoutAsync(AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class StripeAccessGateway(IConfiguration configuration) : IStripeAccessGateway
|
||||
{
|
||||
public async Task<StripeCheckoutResult> CreateCheckoutAsync(
|
||||
AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken)
|
||||
{
|
||||
var secretKey = configuration["Stripe:SecretKey"];
|
||||
if (string.IsNullOrWhiteSpace(secretKey)) throw new InvalidOperationException("Stripe secret key is not configured.");
|
||||
var successUrl = configuration["Stripe:AccessSuccessUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}";
|
||||
var cancelUrl = configuration["Stripe:AccessCancelUrl"]
|
||||
?? "https://doctor.ranaze.com/donation-canceled.html";
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["access_purchase_id"] = purchase.Id ?? "",
|
||||
["access_sku"] = product.Sku,
|
||||
["user_id"] = purchase.UserId
|
||||
};
|
||||
var service = new SessionService(new StripeClient(secretKey));
|
||||
var session = await service.CreateAsync(new SessionCreateOptions
|
||||
{
|
||||
Mode = "payment",
|
||||
SuccessUrl = successUrl,
|
||||
CancelUrl = cancelUrl,
|
||||
ClientReferenceId = purchase.Id,
|
||||
CustomerCreation = "always",
|
||||
BillingAddressCollection = "auto",
|
||||
AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true },
|
||||
Metadata = metadata,
|
||||
PaymentIntentData = new SessionPaymentIntentDataOptions { Metadata = metadata },
|
||||
LineItems =
|
||||
[
|
||||
new SessionLineItemOptions
|
||||
{
|
||||
Quantity = 1,
|
||||
PriceData = new SessionLineItemPriceDataOptions
|
||||
{
|
||||
Currency = product.Currency,
|
||||
UnitAmount = product.AmountCents,
|
||||
ProductData = new SessionLineItemPriceDataProductDataOptions
|
||||
{
|
||||
Name = product.Name,
|
||||
Description = "A manually activated RanaZe access entitlement."
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}, null, cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(session.Id) || string.IsNullOrWhiteSpace(session.Url))
|
||||
throw new InvalidOperationException("Stripe did not return a checkout URL.");
|
||||
return new StripeCheckoutResult(session.Id, session.Url);
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,8 @@ public sealed class UserProfileStore
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserAccessAccounts", "AccessPurchases"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
||||
"Stripe": {
|
||||
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html",
|
||||
"AccessSuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"AccessCancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
},
|
||||
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
|
||||
"PasswordReset": {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"AnswerStatistics": { "ReconcileSeconds": 300 },
|
||||
"Stripe": {
|
||||
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html",
|
||||
"AccessSuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
|
||||
"AccessCancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
|
||||
},
|
||||
"Steam": {
|
||||
"Realm": "https://s3q.ranaze.com",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Access economy
|
||||
|
||||
RanaZe is free to install and use on the public web client and on Steam. An
|
||||
account is required. The server is authoritative for quotas, credits, token
|
||||
inventory, prices, and grants.
|
||||
|
||||
## QBank limits and credits
|
||||
|
||||
- Standard QBank use includes 20 completed questions in a rolling 24-hour
|
||||
window. An answer or skip consumes one slot.
|
||||
- An eligible first answer in the current QBank cycle earns one credit,
|
||||
regardless of correctness. Skips and repeat answers earn no credit.
|
||||
- A user can earn at most 20 credits in a rolling 24-hour window, including
|
||||
while an unlimited pass is active.
|
||||
- 100 earned credits can be redeemed for 24 hours of unlimited standard QBank
|
||||
use. Credits cannot be purchased.
|
||||
- Paid access tokens stay in inventory until manually activated. Activation
|
||||
extends from the later of the current time or the current pass expiration.
|
||||
|
||||
Other study tools remain free. An unlimited QBank pass changes only the standard
|
||||
QBank completion limit.
|
||||
|
||||
## Product catalog
|
||||
|
||||
| SKU | Grant | Price |
|
||||
| --- | --- | ---: |
|
||||
| `access_day_1` | one manually activated 24-hour QBank token | $1.00 |
|
||||
| `access_week_1` | one manually activated 168-hour QBank token | $5.00 |
|
||||
| `access_month_1` | one manually activated 720-hour QBank token | $20.00 |
|
||||
| `exam_token_1` | one exam simulation | $1.99 |
|
||||
| `qbank_reset_1` | one QBank reset | $4.99 |
|
||||
|
||||
A reset requires explicit confirmation and is blocked while an exam simulation
|
||||
is active. It starts a new personal QBank cycle, resets personal QBank rating
|
||||
and current-cycle performance, and makes questions eligible as new samples.
|
||||
Existing global aggregate samples are not removed. Bookmarks, notes, and review
|
||||
content remain.
|
||||
|
||||
Existing accounts begin with zero credits and no tokens. Historical attempts
|
||||
are not backfilled into credits.
|
||||
|
||||
## Web checkout
|
||||
|
||||
Web purchases use Stripe-hosted Checkout. Configure:
|
||||
|
||||
```text
|
||||
Stripe__SecretKey
|
||||
Stripe__AccessWebhookSecret
|
||||
Stripe__AccessSuccessUrl
|
||||
Stripe__AccessCancelUrl
|
||||
```
|
||||
|
||||
Create separate access webhook endpoints:
|
||||
|
||||
- UAT test mode: `https://us3q.ranaze.com/api/access/stripe-webhook`
|
||||
- production live mode: `https://s3q.ranaze.com/api/access/stripe-webhook`
|
||||
|
||||
Each endpoint has a distinct signing secret. Store them as
|
||||
`UAT_STRIPE_ACCESS_WEBHOOK_SECRET` and
|
||||
`PRD_STRIPE_ACCESS_WEBHOOK_SECRET` in Gitea. Subscribe both endpoints to
|
||||
`checkout.session.completed`, `checkout.session.async_payment_succeeded`,
|
||||
`checkout.session.async_payment_failed`, and `checkout.session.expired`.
|
||||
|
||||
Prices and grants are selected from the API catalog; the client never supplies
|
||||
an amount. Webhook signatures, expected amount, currency, session, and purchase
|
||||
ownership are verified before an idempotent grant is applied.
|
||||
|
||||
## Steam checkout
|
||||
|
||||
The Windows Steam export has the `steam` feature flag and routes purchase
|
||||
requests to the disabled Steam checkout endpoint. It does not expose Stripe
|
||||
checkout. The endpoint intentionally returns 503 until the Steamworks AppID,
|
||||
Microtransactions product catalog, server credentials, callbacks,
|
||||
reconciliation, refund handling, and sandbox tests are implemented.
|
||||
|
||||
## Operational checks
|
||||
|
||||
Before release, verify all products in Stripe test mode and then live mode.
|
||||
Exercise duplicate webhook delivery and concurrent quota submissions. Confirm
|
||||
that data export/deletion includes `UserAccessAccounts` and `AccessPurchases`.
|
||||
Monitor rejected checkouts, webhook verification failures, unmatched payments,
|
||||
grant failures, and quota conflicts.
|
||||
@@ -0,0 +1,275 @@
|
||||
# Steam alpha release checklist
|
||||
|
||||
Use this as the go/no-go record for the first external Windows alpha of
|
||||
**RanaZe: Step 3 Trainer**. Put the owner, completion date, and a link to the
|
||||
evidence beside each checked item. An unchecked **BLOCKER** means do not admit
|
||||
testers.
|
||||
|
||||
## Release decision
|
||||
|
||||
- [x] **BLOCKER — Choose the release vehicle.** **Decision: release the app
|
||||
free on Steam and keep the existing public web client available. Both clients
|
||||
use the same account access model. QBank passes and study tokens are optional
|
||||
in-app purchases; Steam purchases remain disabled until Steamworks
|
||||
Microtransactions are configured.**
|
||||
- [ ] Record the decision, target date, tester count, supported countries,
|
||||
tester-selection method, feedback channel, and person with final go/no-go
|
||||
authority.
|
||||
- [ ] Define alpha success and stop conditions: minimum completed sessions,
|
||||
acceptable crash/error rate, critical defect threshold, support response
|
||||
time, server-capacity ceiling, and the date results will be reviewed.
|
||||
- [ ] Define the advertised alpha scope. List working features, known
|
||||
limitations, expected resets or breaking changes, and features explicitly
|
||||
excluded from this test.
|
||||
|
||||
Decision: Free Steam alpha plus the existing public web client; shared freemium access model
|
||||
Target date/time and time zone: ____________________
|
||||
Release owner: ____________________
|
||||
Build commit SHA: ____________________
|
||||
Steam base AppID: ____________________
|
||||
Steam Playtest AppID (if used): ____________________
|
||||
|
||||
## Steamworks account and application
|
||||
|
||||
- [ ] **BLOCKER — Complete Steamworks onboarding.** Verify the legal business
|
||||
name, bank and tax details, identity verification, Steam Distribution
|
||||
Agreement, and team permissions.
|
||||
- [ ] Pay and activate the Steam Direct app credit for the base application
|
||||
(currently USD 100 per product, subject to tax).
|
||||
- [ ] Create the base application and save its AppID above.
|
||||
- [ ] For a Playtest, create the child Playtest app from **Associated Packages
|
||||
& DLC**, choose its public name carefully, and save its AppID above. The
|
||||
public Playtest name cannot be changed after release.
|
||||
- [ ] Restrict Steamworks permissions to the people who need them; require
|
||||
Steam Guard and make sure at least two trusted people can handle a release.
|
||||
- [ ] Decide whether the main store page will be public. A Playtest can instead
|
||||
use keys without making signups or the store page public, but testers should
|
||||
not be promised confidentiality merely because access is limited.
|
||||
|
||||
## Content, legal, and privacy clearance
|
||||
|
||||
- [ ] **BLOCKER — Complete the repository's outstanding originality review for
|
||||
every released question and explanation.** Retain reviewer, date, sources,
|
||||
revisions, and disposition.
|
||||
- [ ] **BLOCKER — Complete the outstanding copyright/license audit for every
|
||||
image-bearing question.** Remove or replace anything whose reuse rights
|
||||
cannot be established.
|
||||
- [ ] **BLOCKER — Complete the duplicate-question review and authoritative
|
||||
reference verification described in `README.md`.** Release only published,
|
||||
unflagged, cleared content.
|
||||
- [ ] Review all other shipped assets—branding, fonts, icons, audio, code,
|
||||
screenshots, trailer footage, and third-party notices—and retain a license
|
||||
inventory.
|
||||
- [ ] Have qualified counsel/ownership review the Terms of Service, Privacy
|
||||
Policy, educational/medical disclaimer, trademark claims, refund/customer
|
||||
terms, and the handling of study notes or other user-entered data.
|
||||
- [ ] Confirm the Steam store description and screenshots never imply medical
|
||||
advice, clinical certification, guaranteed exam performance, or affiliation
|
||||
with USMLE, NBME, FSMB, or a licensing authority.
|
||||
- [ ] Complete Steam's Content Survey honestly and make its answers consistent
|
||||
with both the build and store page.
|
||||
- [ ] Verify `[email protected]` is monitored and that account data export,
|
||||
permanent deletion, provider unlinking, and privacy requests work end to end.
|
||||
- [ ] Publish and test the production legal pages:
|
||||
`https://step3.ranaze.com/terms.html` and
|
||||
`https://step3.ranaze.com/privacy.html`.
|
||||
|
||||
## Production service readiness
|
||||
|
||||
- [ ] **BLOCKER — Complete the initial PRD launch and question promotion in
|
||||
`ENVIRONMENTS.md`, including dry run, approval, apply, and post-promotion
|
||||
verification.**
|
||||
- [ ] Confirm PRD uses a unique JWT key, PRD Mongo database, production host
|
||||
names, production legal versions, and production secrets; no UAT data or
|
||||
credentials may be present.
|
||||
- [ ] Configure and test the live Steam callbacks, including:
|
||||
`https://s3q.ranaze.com/api/Auth/steam/callback` and
|
||||
`https://s3q.ranaze.com/api/Auth/external/steam/callback`.
|
||||
- [ ] From a clean Steam account, test create-account, link-existing-account,
|
||||
sign-out/sign-in, expired/cancelled authentication, unlinking, and account
|
||||
deletion. Confirm the app never asks for or receives a Steam password.
|
||||
- [ ] **BLOCKER — Configure and test Stripe access purchases on the web** at
|
||||
both `https://us3q.ranaze.com/api/access/stripe-webhook` in test mode and
|
||||
`https://s3q.ranaze.com/api/access/stripe-webhook` in live mode. Use distinct
|
||||
signing secrets, subscribe the required Checkout events, and test all five
|
||||
fixed-price products. Keep the separate contribution flow only if wanted.
|
||||
- [ ] **BLOCKER — Keep Stripe checkout unavailable in the Steam build.** Before
|
||||
enabling purchases there, configure Steamworks Microtransactions, product
|
||||
IDs, callbacks, reconciliation, refunds, and sandbox tests. Confirm the
|
||||
desktop export carries the `steam` feature flag.
|
||||
- [ ] Exercise production readiness endpoints and dashboards, and configure
|
||||
alerts for API unavailability, elevated 5xx responses, authentication
|
||||
failures, database capacity, and certificate expiry.
|
||||
- [ ] Load-test the API at the planned tester count plus headroom. Document a
|
||||
tester-admission cap and who can stop new admissions.
|
||||
- [ ] Take and restore-test a production database backup. Record retention,
|
||||
rollback, incident-response, and credential-rotation procedures.
|
||||
|
||||
## Windows release build
|
||||
|
||||
- [ ] Install the required Godot 4.6.x release export templates and confirm the
|
||||
pinned engine version used for the release.
|
||||
- [ ] Update all visible versions from `pre-alpha`/`0.1.0` to the approved alpha
|
||||
version in `project.godot`, `export_presets.cfg`, the UI, and release notes.
|
||||
- [ ] Decide whether to disable the Windows console wrapper
|
||||
(`debug/export_console_wrapper=1`) for tester-facing builds.
|
||||
- [ ] Decide whether to Authenticode-sign `RanaZe.exe`; if signing, protect the
|
||||
certificate, timestamp the executable, and verify the signature.
|
||||
- [ ] Export only from the reviewed release commit, with a clean working tree,
|
||||
using Git Bash:
|
||||
|
||||
```bash
|
||||
GODOT_BIN=/absolute/path/to/Godot_v4.6.x-stable_win64_console.exe \
|
||||
./tools/export_steam_release.sh build/windows/RanaZe.exe
|
||||
```
|
||||
|
||||
- [ ] **BLOCKER — Verify the export reports `deployment_environment="prd"`,
|
||||
the exact release commit in `build_revision`, and only `s3q.ranaze.com`
|
||||
production API endpoints.**
|
||||
- [ ] Confirm the upload directory contains `RanaZe.exe`, `RanaZe.pck`, and all
|
||||
runtime dependencies, but no source, secrets, `.env` files, export backup,
|
||||
debug symbols not intended for testers, or test data.
|
||||
- [ ] Virus-scan the staged files and archive a manifest containing SHA-256
|
||||
hashes, build command, Godot version, commit SHA, build time, and builder.
|
||||
|
||||
## SteamPipe and Steam configuration
|
||||
|
||||
- [ ] Download the current Steamworks SDK. The SDK is required to upload with
|
||||
SteamPipe; integrating the Steamworks runtime API is optional for this app's
|
||||
existing browser-based Steam OpenID flow.
|
||||
- [ ] Create the Windows depot and default package for the selected AppID.
|
||||
- [ ] Create version-controlled SteamPipe app/depot VDF templates with AppIDs
|
||||
but no Steam credentials. Point the depot content root at the staged Windows
|
||||
directory.
|
||||
- [ ] Upload through `steamcmd`, add a human-readable build description with
|
||||
the alpha version and commit SHA, and first assign it to a password-protected
|
||||
`alpha` branch.
|
||||
- [ ] Configure the Windows launch option to run `RanaZe.exe` from the depot
|
||||
root. Verify OS is Windows and architecture is 64-bit.
|
||||
- [ ] Set supported language(s) to match what is actually available in the
|
||||
build. Do not claim interface, audio, or subtitle support that has not been
|
||||
tested.
|
||||
- [ ] Configure install/uninstall behavior and required redistributables, if
|
||||
any. Confirm uninstall does not remove cloud/server account data and that
|
||||
locally generated sensitive files are understood.
|
||||
- [ ] If using a Playtest, upload and configure the build on the **Playtest
|
||||
AppID**, not merely the base game's depot.
|
||||
- [ ] Test installation, launch, update, rollback, file verification, uninstall,
|
||||
and reinstall entirely through the Steam client—never only by running the
|
||||
local export.
|
||||
|
||||
## Store and tester communication
|
||||
|
||||
- [ ] Prepare truthful short and long descriptions, developer/publisher names,
|
||||
tags, platform/language support, system requirements, support contact, and
|
||||
privacy-policy URL.
|
||||
- [ ] Create required store, library, community, and client graphical assets at
|
||||
Steam's current dimensions. Use actual alpha screenshots and clearly label
|
||||
footage or features that are not representative of the current build.
|
||||
- [ ] Check all asset rights and Steam's graphical asset rules. Localize text
|
||||
appearing in graphical assets for every language the game claims to support.
|
||||
- [ ] For Early Access only, complete the Early Access questionnaire, describe
|
||||
the current state and update plan without promises the team cannot guarantee,
|
||||
set a price justified by the current build, and use required Early Access
|
||||
branding wherever keys are sold.
|
||||
- [ ] Draft tester instructions covering scope, known issues, medical
|
||||
disclaimer, feedback/bug-report format, log/screenshot handling, privacy,
|
||||
support hours, update/reset expectations, and test end date.
|
||||
- [ ] Draft the launch announcement, first patch notes, outage message, and
|
||||
alpha-ending message before admitting testers.
|
||||
|
||||
## Release-candidate acceptance
|
||||
|
||||
- [ ] Promote the exact uploaded build to the protected `alpha` branch and have
|
||||
at least two people test it independently on clean Windows 10 and Windows 11
|
||||
user profiles.
|
||||
- [ ] Test at 1920×1080 plus a smaller supported resolution and at 100%, 125%,
|
||||
and 150% Windows scaling. Check keyboard/mouse navigation, text clipping,
|
||||
external browser transitions, focus return, and readable error states.
|
||||
- [ ] Run the complete learning journey: registration and legal consent,
|
||||
practice question, randomized choices, answer/explanation/reference,
|
||||
dashboard, review queue, notes/bookmarks, flashcards, CCS, feedback/report,
|
||||
data export, and account deletion.
|
||||
- [ ] Run the access journey on web: questions 1–20, rolling-window exhaustion,
|
||||
one credit per eligible first attempt, no credit for skips/repeats, the
|
||||
20-credit earning cap, 100-credit redemption, purchase and manual activation
|
||||
of each pass, exam-token consumption, reset confirmation, and retained
|
||||
bookmarks/notes after reset.
|
||||
- [ ] Test offline start, connection loss during a session, API timeout, expired
|
||||
token, server maintenance, malformed response, and recovery without corrupting
|
||||
learner progress.
|
||||
- [ ] Run automated API/Godot tests and the production-equivalent smoke suite;
|
||||
attach results to the release record. Do not rely only on the web UAT smoke
|
||||
suite for the Windows Steam build.
|
||||
- [ ] Verify every feature and claim shown on the store page exists in the
|
||||
submitted build.
|
||||
- [ ] Triage all findings. Zero unresolved critical issues; every accepted
|
||||
non-critical issue has an owner, rationale, workaround, and target release.
|
||||
- [ ] Obtain written sign-off from engineering, content/medical review, legal/
|
||||
privacy, operations, and the release owner.
|
||||
|
||||
## Valve review and scheduling
|
||||
|
||||
- [ ] Complete the selected app's Steamworks store-presence checklist and submit
|
||||
it for review. Valve advises allowing at least seven business days even though
|
||||
reviews commonly take three to five business days.
|
||||
- [ ] After store presence is submitted, complete the game-build checklist,
|
||||
place a mostly final build on the default branch for Valve, and submit the
|
||||
build for review.
|
||||
- [ ] Resolve every Valve review note and repeat the relevant acceptance tests
|
||||
against any changed build or store material.
|
||||
- [ ] If publishing the main Coming Soon page, allow at least the current
|
||||
Steam-required visibility period before an Early Access/full release. Confirm
|
||||
the exact eligible release date shown in Steamworks rather than calculating
|
||||
it from memory.
|
||||
- [ ] For a Playtest, choose limited or open signup, batch size/countries, or
|
||||
key distribution. Never charge for Playtest access or sell Playtest keys.
|
||||
- [ ] Schedule the release while the release owner, backend operator, and
|
||||
support contact are available for the initial monitoring window.
|
||||
|
||||
## Launch day
|
||||
|
||||
- [ ] Freeze unrelated PRD and client changes; confirm the approved commit,
|
||||
Steam build ID, depot manifest, store copy, tester count, and rollback build.
|
||||
- [ ] Re-run public health/readiness checks, login, one question flow, feedback,
|
||||
legal links, database backup status, alerts, and Steam callback.
|
||||
- [ ] Set the approved build live on the intended Steam branch.
|
||||
- [ ] For a Playtest, set the app **Playable**, then admit only the planned
|
||||
first batch or enable the approved signup mode.
|
||||
- [ ] Install and launch from a tester account with no developer permissions.
|
||||
Verify download size, executable, version/SHA, production environment, and
|
||||
end-to-end authentication.
|
||||
- [ ] Publish tester instructions/announcement and open the staffed feedback
|
||||
channel.
|
||||
- [ ] Record release time, Steam build ID, admitted tester count, incidents,
|
||||
decisions, and changes in the release log.
|
||||
|
||||
## Monitoring, rollback, and alpha close
|
||||
|
||||
- [ ] Monitor API/error/auth rates, capacity, reports, support inbox, Steam
|
||||
discussions, and content-safety feedback at the defined cadence.
|
||||
- [ ] Acknowledge critical medical-accuracy or copyright reports immediately;
|
||||
unpublish affected content while it is reviewed.
|
||||
- [ ] Stop admissions for capacity or support overload. Set the Playtest to
|
||||
**Not Playable** or roll back its live branch when a stop condition is met,
|
||||
and communicate the impact to testers.
|
||||
- [ ] Patch from a reviewed commit, repeat affected acceptance tests, upload to
|
||||
the protected branch, test through Steam, then promote the exact build.
|
||||
- [ ] At alpha end, announce the deadline, stop signups, communicate account/
|
||||
progress retention, and set the Playtest to **Not Playable** when appropriate.
|
||||
- [ ] Export metrics without exposing personal data; triage feedback into owned
|
||||
work; hold a retrospective; document the next test's entry criteria.
|
||||
- [ ] Retain build manifests, approvals, test evidence, incidents, content
|
||||
review records, and the final release log according to the team's policy.
|
||||
|
||||
## Official Steam references
|
||||
|
||||
- [Steam Playtest](https://partner.steamgames.com/doc/features/playtest)
|
||||
- [Steam release process](https://partner.steamgames.com/doc/store/releasing)
|
||||
- [Steam review process](https://partner.steamgames.com/doc/store/review_process)
|
||||
- [Uploading to Steam / SteamPipe](https://partner.steamgames.com/doc/sdk/uploading)
|
||||
- [Steam Direct fee](https://partner.steamgames.com/doc/gettingstarted/appfee)
|
||||
- [Steamworks onboarding](https://partner.steamgames.com/doc/gettingstarted/onboarding)
|
||||
- [Content Survey](https://partner.steamgames.com/doc/gettingstarted/contentsurvey)
|
||||
- [Early Access](https://partner.steamgames.com/doc/store/earlyaccess)
|
||||
- [Store graphical assets](https://partner.steamgames.com/doc/store/assets/standard)
|
||||
@@ -7,9 +7,9 @@ Contributions are not tax-deductible.
|
||||
## Stripe test-mode setup
|
||||
|
||||
1. In the Stripe Dashboard, switch to test mode.
|
||||
2. Create a webhook endpoint at:
|
||||
2. Create the UAT test-mode webhook endpoint at:
|
||||
|
||||
`https://s3q.ranaze.com/api/donations/stripe-webhook`
|
||||
`https://us3q.ranaze.com/api/donations/stripe-webhook`
|
||||
|
||||
3. Subscribe the endpoint to these events:
|
||||
|
||||
@@ -22,10 +22,12 @@ Contributions are not tax-deductible.
|
||||
5. Copy a test secret API key (`sk_test_...`).
|
||||
6. Add both values as Gitea Actions secrets in the API repository:
|
||||
|
||||
- `STRIPE_SECRET_KEY`
|
||||
- `STRIPE_WEBHOOK_SECRET`
|
||||
- `UAT_STRIPE_SECRET_KEY`
|
||||
- `UAT_STRIPE_WEBHOOK_SECRET`
|
||||
|
||||
The deployment workflow writes these values to the Kubernetes secret `medmind-stripe`. Never commit either value to the repository.
|
||||
The deployment workflow writes these values to the environment's
|
||||
`step3-runtime-secrets` Kubernetes secret. Never commit either value to the
|
||||
repository.
|
||||
|
||||
## Stripe account settings
|
||||
|
||||
@@ -47,9 +49,10 @@ The client-visible status endpoint returns only the donation ID, amount, currenc
|
||||
|
||||
## Live-mode cutover
|
||||
|
||||
1. Create the same webhook endpoint in Stripe live mode.
|
||||
2. Replace `STRIPE_SECRET_KEY` with the live secret key (`sk_live_...`).
|
||||
3. Replace `STRIPE_WEBHOOK_SECRET` with the live endpoint's signing secret.
|
||||
1. Create `https://s3q.ranaze.com/api/donations/stripe-webhook` in Stripe live
|
||||
mode.
|
||||
2. Set `PRD_STRIPE_SECRET_KEY` to the live secret key (`sk_live_...`).
|
||||
3. Set `PRD_STRIPE_WEBHOOK_SECRET` to that endpoint's signing secret.
|
||||
4. Trigger a deployment and complete a small real contribution.
|
||||
5. Confirm the payment, receipt, webhook delivery, and `paid` status before announcing availability.
|
||||
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ platform="Windows Desktop"
|
||||
runnable=true
|
||||
advanced_options=false
|
||||
dedicated_server=false
|
||||
custom_features=""
|
||||
custom_features="steam"
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
|
||||
@@ -56,6 +56,11 @@ spec:
|
||||
secretKeyRef:
|
||||
name: step3-runtime-secrets
|
||||
key: stripe-webhook-secret
|
||||
- name: Stripe__AccessWebhookSecret
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-runtime-secrets
|
||||
key: stripe-access-webhook-secret
|
||||
- name: Google__ClientId
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
|
||||
@@ -29,5 +29,7 @@ configMapGenerator:
|
||||
- Legal__PrivacyUrl=https://step3.ranaze.com/privacy.html
|
||||
- Stripe__SuccessUrl=https://step3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__CancelUrl=https://step3.ranaze.com/donation-canceled.html
|
||||
- Stripe__AccessSuccessUrl=https://step3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__AccessCancelUrl=https://step3.ranaze.com/donation-canceled.html
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
@@ -26,5 +26,7 @@ configMapGenerator:
|
||||
- Legal__PrivacyUrl=https://ustep3.ranaze.com/privacy.html
|
||||
- Stripe__SuccessUrl=https://ustep3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__CancelUrl=https://ustep3.ranaze.com/donation-canceled.html
|
||||
- Stripe__AccessSuccessUrl=https://ustep3.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}
|
||||
- Stripe__AccessCancelUrl=https://ustep3.ranaze.com/donation-canceled.html
|
||||
generatorOptions:
|
||||
disableNameSuffixHash: true
|
||||
|
||||
@@ -43,6 +43,7 @@ DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
||||
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
|
||||
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
|
||||
DonationService="*res://scripts/autoload/donation_service.gd"
|
||||
AccessService="*res://scripts/autoload/access_service.gd"
|
||||
|
||||
[display]
|
||||
|
||||
@@ -72,6 +73,7 @@ notes_api_url="https://us3q.ranaze.com/api/notes"
|
||||
dashboard_api_url="https://us3q.ranaze.com/api/dashboard"
|
||||
profile_api_url="https://us3q.ranaze.com/api/profile"
|
||||
donations_api_url="https://us3q.ranaze.com/api/donations"
|
||||
access_api_url="https://us3q.ranaze.com/api/access"
|
||||
review_queue_api_url="https://us3q.ranaze.com/api/review-queue"
|
||||
flashcards_api_url="https://us3q.ranaze.com/api/flashcards"
|
||||
build_revision="development"
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
extends Node
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/access"
|
||||
const REQUEST_TIMEOUT_SECONDS := 30.0
|
||||
const POLL_INTERVAL_SECONDS := 3.0
|
||||
const POLL_LIMIT := 200
|
||||
const CHECKOUT_STORAGE_KEY := "ranaze_access_checkout_url"
|
||||
|
||||
signal status_changed(status: Dictionary)
|
||||
signal operation_succeeded(message: String)
|
||||
signal operation_failed(message: String)
|
||||
signal checkout_started(purchase_id: String, checkout_url: String)
|
||||
|
||||
var api_url := DEFAULT_URL
|
||||
var status: Dictionary = {}
|
||||
var status_request: HTTPRequest
|
||||
var action_request: HTTPRequest
|
||||
var purchase_request: HTTPRequest
|
||||
var poll_timer: Timer
|
||||
var pending_action := ""
|
||||
var request_token := ""
|
||||
var active_purchase_id := ""
|
||||
var poll_count := 0
|
||||
var checkout_popup_opened := false
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
api_url = EndpointConfig.resolve(
|
||||
str(ProjectSettings.get_setting("step3/access_api_url", DEFAULT_URL)),
|
||||
"/api/access")
|
||||
status_request = _request_node(_on_status_completed)
|
||||
action_request = _request_node(_on_action_completed)
|
||||
purchase_request = _request_node(_on_purchase_status_completed)
|
||||
poll_timer = Timer.new()
|
||||
poll_timer.wait_time = POLL_INTERVAL_SECONDS
|
||||
poll_timer.timeout.connect(_poll_purchase)
|
||||
add_child(poll_timer)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
if AuthState.is_logged_in:
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if not AuthState.can_send_authenticated_request() or status_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
request_token = AuthState.access_token
|
||||
status_request.request(api_url, PackedStringArray(["Authorization: Bearer %s" % request_token]))
|
||||
|
||||
|
||||
func redeem_credits() -> void:
|
||||
_send_action("redeem", "/redeem-credits", {"operationId": _operation_id("credit-pass")})
|
||||
|
||||
|
||||
func activate_token(token: String) -> void:
|
||||
_send_action("activate", "/activate-token", {
|
||||
"token": token,
|
||||
"operationId": _operation_id("activate-%s" % token)
|
||||
})
|
||||
|
||||
|
||||
func reset_qbank() -> void:
|
||||
_send_action("reset", "/qbank-reset", {
|
||||
"confirmation": "RESET_QBANK",
|
||||
"operationId": _operation_id("qbank-reset")
|
||||
})
|
||||
|
||||
|
||||
func begin_checkout(sku: String) -> void:
|
||||
if OS.has_feature("steam"):
|
||||
_send_action("steam_checkout", "/steam-checkouts", {"sku": sku})
|
||||
return
|
||||
if OS.has_feature("web") and not _prepare_browser_window():
|
||||
operation_failed.emit("Checkout needs a popup. Allow popups for this site, then try again.")
|
||||
return
|
||||
_send_action("checkout", "/web-checkouts", {"sku": sku})
|
||||
|
||||
|
||||
func apply_embedded_status(value: Variant) -> void:
|
||||
if typeof(value) != TYPE_DICTIONARY:
|
||||
return
|
||||
status = value.duplicate(true)
|
||||
status_changed.emit(status)
|
||||
|
||||
|
||||
func has_unlimited_access() -> bool:
|
||||
return bool(status.get("unlimited", false))
|
||||
|
||||
|
||||
func questions_remaining() -> int:
|
||||
return int(status.get("questionsRemaining", 20))
|
||||
|
||||
|
||||
func _send_action(action: String, path: String, payload: Dictionary) -> void:
|
||||
if pending_action != "":
|
||||
operation_failed.emit("Another access request is still in progress.")
|
||||
return
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
operation_failed.emit("Log in before managing access.")
|
||||
return
|
||||
pending_action = action
|
||||
request_token = AuthState.access_token
|
||||
var error := action_request.request(
|
||||
"%s%s" % [api_url.rstrip("/"), path],
|
||||
PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
"Authorization: Bearer %s" % request_token
|
||||
]),
|
||||
HTTPClient.METHOD_POST,
|
||||
JSON.stringify(payload))
|
||||
if error != OK:
|
||||
pending_action = ""
|
||||
operation_failed.emit("The access request could not be started.")
|
||||
|
||||
|
||||
func _on_status_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
apply_embedded_status(parsed)
|
||||
|
||||
|
||||
func _on_action_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var action := pending_action
|
||||
pending_action = ""
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
_close_browser_window()
|
||||
operation_failed.emit(_failure_message("Access request failed.", response_code, body))
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if action == "checkout":
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
_close_browser_window()
|
||||
operation_failed.emit("Checkout returned an invalid response.")
|
||||
return
|
||||
active_purchase_id = str(parsed.get("purchaseId", ""))
|
||||
var checkout_url := str(parsed.get("checkoutUrl", ""))
|
||||
if active_purchase_id.is_empty() or not checkout_url.begins_with("https://"):
|
||||
_close_browser_window()
|
||||
operation_failed.emit("Checkout returned an invalid link.")
|
||||
return
|
||||
_open_checkout_url(checkout_url)
|
||||
checkout_started.emit(active_purchase_id, checkout_url)
|
||||
poll_count = 0
|
||||
poll_timer.start()
|
||||
_poll_purchase()
|
||||
return
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
apply_embedded_status(parsed)
|
||||
operation_succeeded.emit({
|
||||
"redeem": "Your 24-hour QBank pass is active.",
|
||||
"activate": "Your access token was activated.",
|
||||
"reset": "Your QBank has been reset for a new cycle."
|
||||
}.get(action, "Access updated."))
|
||||
|
||||
|
||||
func _poll_purchase() -> void:
|
||||
if active_purchase_id.is_empty() or not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
if purchase_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
return
|
||||
poll_count += 1
|
||||
if poll_count > POLL_LIMIT:
|
||||
poll_timer.stop()
|
||||
return
|
||||
request_token = AuthState.access_token
|
||||
purchase_request.request(
|
||||
"%s/purchases/%s" % [api_url.rstrip("/"), active_purchase_id.uri_encode()],
|
||||
PackedStringArray(["Authorization: Bearer %s" % request_token]))
|
||||
|
||||
|
||||
func _on_purchase_status_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
return
|
||||
match str(parsed.get("status", "")):
|
||||
"paid":
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
active_purchase_id = ""
|
||||
operation_succeeded.emit("Purchase confirmed. Your token is ready to activate.")
|
||||
refresh()
|
||||
"failed", "expired", "refunded":
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
active_purchase_id = ""
|
||||
operation_failed.emit("The purchase was not completed.")
|
||||
|
||||
|
||||
func _request_node(callback: Callable) -> HTTPRequest:
|
||||
var node := HTTPRequest.new()
|
||||
node.timeout = REQUEST_TIMEOUT_SECONDS
|
||||
node.request_completed.connect(callback)
|
||||
add_child(node)
|
||||
return node
|
||||
|
||||
|
||||
func _operation_id(prefix: String) -> String:
|
||||
return "%s-%d-%d" % [prefix, Time.get_ticks_usec(), randi()]
|
||||
|
||||
|
||||
func _open_checkout_url(url: String) -> void:
|
||||
if OS.has_feature("web"):
|
||||
JavaScriptBridge.eval(
|
||||
"var ranazeUrl=%s;window.localStorage.setItem('%s',ranazeUrl);if(window.ranazeAccessWindow&&!window.ranazeAccessWindow.closed){window.ranazeAccessWindow.location.replace(ranazeUrl);window.ranazeAccessWindow.focus();}else{window.location.assign(ranazeUrl);}" %
|
||||
[JSON.stringify(url), CHECKOUT_STORAGE_KEY], true)
|
||||
else:
|
||||
OS.shell_open(url)
|
||||
|
||||
|
||||
func _prepare_browser_window() -> bool:
|
||||
checkout_popup_opened = bool(JavaScriptBridge.eval(
|
||||
"window.ranazeAccessWindow=window.open('about:blank','ranaze_access','popup=yes,width=620,height=760,resizable=yes,scrollbars=yes');window.ranazeAccessWindow!==null;",
|
||||
true))
|
||||
return checkout_popup_opened
|
||||
|
||||
|
||||
func _close_browser_window() -> void:
|
||||
if OS.has_feature("web") and checkout_popup_opened:
|
||||
JavaScriptBridge.eval(
|
||||
"window.localStorage.removeItem('%s');if(window.ranazeAccessWindow&&!window.ranazeAccessWindow.closed){window.ranazeAccessWindow.close();}window.ranazeAccessWindow=null;window.focus();" %
|
||||
CHECKOUT_STORAGE_KEY, true)
|
||||
checkout_popup_opened = false
|
||||
|
||||
|
||||
func _failure_message(prefix: String, response_code: int, body: PackedByteArray) -> String:
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
var message := str(parsed.get("message", parsed.get("detail", ""))).strip_edges()
|
||||
if not message.is_empty():
|
||||
return message
|
||||
return "%s Status %d." % [prefix, response_code]
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if AuthState.is_logged_in:
|
||||
refresh()
|
||||
else:
|
||||
status = {}
|
||||
active_purchase_id = ""
|
||||
poll_timer.stop()
|
||||
_close_browser_window()
|
||||
status_changed.emit(status)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ch2scupdmgjpj
|
||||
@@ -19,6 +19,7 @@ signal question_rating_updated(question_id: String, question_elo: int, rating: D
|
||||
signal question_skipped(question_id: String, correct_choice_id: String, question_elo: int, rating: Dictionary)
|
||||
signal question_report_submitted(question_id: String, report_id: String)
|
||||
signal question_report_failed(question_id: String, message: String)
|
||||
signal access_required(message: String)
|
||||
|
||||
var questions: Array = []
|
||||
var request: HTTPRequest
|
||||
@@ -157,6 +158,14 @@ func _on_api_questions_loaded(result: int, response_code: int, _headers: PackedS
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(questions_request_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
if response_code == 403:
|
||||
var denied: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(denied) == TYPE_DICTIONARY and str(denied.get("code", "")) == "qbank_limit_reached":
|
||||
AccessService.apply_embedded_status(denied.get("access", {}))
|
||||
var denied_message := str(denied.get("message", "Your free QBank allowance is exhausted."))
|
||||
_set_load_error(denied_message, false)
|
||||
access_required.emit(denied_message)
|
||||
return
|
||||
var message := "The connection to the question service was interrupted."
|
||||
if response_code > 0:
|
||||
message = "Questions API request failed with status %d." % response_code
|
||||
@@ -400,6 +409,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
|
||||
"choice_id": choice_id,
|
||||
"presented_choice_ids": presented_choice_ids.duplicate(),
|
||||
"duration_seconds": maxi(0, duration_seconds),
|
||||
"completion_id": _new_completion_id(question_id),
|
||||
"retry_count": 0
|
||||
})
|
||||
_save_attempt_queue()
|
||||
@@ -421,6 +431,10 @@ func _process_attempt_queue() -> void:
|
||||
return
|
||||
|
||||
var queued: Dictionary = attempt_queue[active_attempt_index]
|
||||
if str(queued.get("completion_id", "")).is_empty():
|
||||
queued["completion_id"] = _new_completion_id(str(queued.get("question_id", "")))
|
||||
attempt_queue[active_attempt_index] = queued
|
||||
_save_attempt_queue()
|
||||
attempt_request_token = AuthState.access_token
|
||||
var headers := PackedStringArray([
|
||||
"Content-Type: application/json",
|
||||
@@ -429,13 +443,18 @@ func _process_attempt_queue() -> void:
|
||||
var question_id := str(queued.get("question_id", ""))
|
||||
var url := question_attempt_api_url
|
||||
var body := JSON.stringify({
|
||||
"completionId": str(queued.get("completion_id", "")),
|
||||
"questionExternalId": question_id,
|
||||
"choiceId": str(queued.get("choice_id", "")),
|
||||
"presentedChoiceIds": queued.get("presented_choice_ids", []),
|
||||
"durationSeconds": int(queued.get("duration_seconds", 0))
|
||||
})
|
||||
if str(queued.get("kind", "answer")) == "skip":
|
||||
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
||||
url = "%s/%s/skip?completionId=%s" % [
|
||||
question_api_base_url.rstrip("/"),
|
||||
question_id.uri_encode(),
|
||||
str(queued.get("completion_id", "")).uri_encode()
|
||||
]
|
||||
body = ""
|
||||
|
||||
attempt_request_active = true
|
||||
@@ -474,7 +493,14 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
if response_code == 401:
|
||||
AuthState.handle_unauthorized(attempt_request_token)
|
||||
active_attempt_index = -1
|
||||
elif response_code in [400, 403, 404]:
|
||||
elif response_code == 403:
|
||||
var denied: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(denied) == TYPE_DICTIONARY:
|
||||
AccessService.apply_embedded_status(denied.get("access", {}))
|
||||
access_required.emit(str(denied.get("message", "Your free QBank allowance is exhausted.")))
|
||||
_remove_active_attempt()
|
||||
_process_attempt_queue()
|
||||
elif response_code in [400, 404]:
|
||||
push_warning("Discarding invalid question attempt with status %d." % response_code)
|
||||
_remove_active_attempt()
|
||||
_process_attempt_queue()
|
||||
@@ -492,9 +518,14 @@ func _on_first_attempt_recorded(result: int, response_code: int, _headers: Packe
|
||||
_apply_skip_response(parsed)
|
||||
else:
|
||||
_apply_first_attempt_response(parsed)
|
||||
AccessService.apply_embedded_status(parsed.get("access", {}))
|
||||
_process_attempt_queue()
|
||||
|
||||
|
||||
func _new_completion_id(question_id: String) -> String:
|
||||
return "%s-%d-%d" % [question_id.sha256_text().left(12), Time.get_ticks_usec(), randi()]
|
||||
|
||||
|
||||
func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
||||
|
||||
var question_id := str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
extends MarginContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
|
||||
var main: Control
|
||||
var summary: Label
|
||||
var quota: Label
|
||||
var status_label: Label
|
||||
var token_labels: Dictionary = {}
|
||||
var action_buttons: Array[Button] = []
|
||||
var reset_dialog: ConfirmationDialog
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_build_ui()
|
||||
AccessService.status_changed.connect(_render)
|
||||
AccessService.operation_succeeded.connect(_on_success)
|
||||
AccessService.operation_failed.connect(_on_failure)
|
||||
AccessService.checkout_started.connect(_on_checkout_started)
|
||||
UIThemeService.theme_changed.connect(refresh_theme_controls)
|
||||
AccessService.refresh()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
AccessService.refresh()
|
||||
_render(AccessService.status)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
add_child(scroll)
|
||||
var page := VBoxContainer.new()
|
||||
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
page.add_theme_constant_override("separation", 20)
|
||||
scroll.add_child(page)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Credits & Access"
|
||||
title.add_theme_font_override("font", Brand.font)
|
||||
title.add_theme_font_size_override("font_size", 36)
|
||||
page.add_child(title)
|
||||
var intro := Label.new()
|
||||
intro.text = "Study 20 QBank questions in a rolling 24-hour window. Eligible first attempts earn credits; 100 credits unlocks unlimited QBank access for 24 hours."
|
||||
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
intro.add_theme_font_override("font", Brand.font)
|
||||
intro.add_theme_font_size_override("font_size", 17)
|
||||
page.add_child(intro)
|
||||
|
||||
var overview := _panel()
|
||||
page.add_child(overview)
|
||||
var overview_content := VBoxContainer.new()
|
||||
overview_content.add_theme_constant_override("separation", 10)
|
||||
overview.add_child(overview_content)
|
||||
summary = _label("", 24)
|
||||
quota = _label("", 17)
|
||||
overview_content.add_child(summary)
|
||||
overview_content.add_child(quota)
|
||||
var redeem := _button("Redeem 100 credits for 24 hours", AccessService.redeem_credits)
|
||||
overview_content.add_child(redeem)
|
||||
|
||||
var token_grid := GridContainer.new()
|
||||
token_grid.columns = 3
|
||||
token_grid.add_theme_constant_override("h_separation", 16)
|
||||
token_grid.add_theme_constant_override("v_separation", 16)
|
||||
page.add_child(token_grid)
|
||||
_add_access_card(token_grid, "day", "1 day", "$1.00", "24 hours")
|
||||
_add_access_card(token_grid, "week", "1 week", "$5.00", "168 hours")
|
||||
_add_access_card(token_grid, "month", "1 month", "$20.00", "30 days")
|
||||
|
||||
var extras := _panel()
|
||||
page.add_child(extras)
|
||||
var extras_content := VBoxContainer.new()
|
||||
extras_content.add_theme_constant_override("separation", 12)
|
||||
extras.add_child(extras_content)
|
||||
extras_content.add_child(_label("Study tools", 24))
|
||||
token_labels["exam"] = _label("", 16)
|
||||
extras_content.add_child(token_labels["exam"])
|
||||
extras_content.add_child(_button("Buy exam simulation token — $1.99",
|
||||
func(): AccessService.begin_checkout("exam_token_1")))
|
||||
token_labels["reset"] = _label("", 16)
|
||||
extras_content.add_child(token_labels["reset"])
|
||||
extras_content.add_child(_button("Buy QBank reset token — $4.99",
|
||||
func(): AccessService.begin_checkout("qbank_reset_1")))
|
||||
extras_content.add_child(_button("Use QBank reset token", _confirm_reset))
|
||||
|
||||
status_label = _label("", 15)
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
page.add_child(status_label)
|
||||
|
||||
reset_dialog = ConfirmationDialog.new()
|
||||
reset_dialog.title = "Reset QBank progress?"
|
||||
reset_dialog.dialog_text = "This starts a new QBank cycle. Questions become unused and credit-eligible again. Historical aggregate samples, bookmarks, notes, and review content remain."
|
||||
reset_dialog.ok_button_text = "Reset QBank"
|
||||
reset_dialog.confirmed.connect(AccessService.reset_qbank)
|
||||
add_child(reset_dialog)
|
||||
refresh_theme_controls()
|
||||
|
||||
|
||||
func _add_access_card(parent: GridContainer, token: String, heading: String, price: String, duration: String) -> void:
|
||||
var panel := _panel()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
parent.add_child(panel)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(content)
|
||||
content.add_child(_label(heading, 24))
|
||||
content.add_child(_label("%s of unlimited QBank access" % duration, 15))
|
||||
token_labels[token] = _label("", 15)
|
||||
content.add_child(token_labels[token])
|
||||
content.add_child(_button("Buy token — %s" % price,
|
||||
func(): AccessService.begin_checkout("access_%s_1" % token)))
|
||||
content.add_child(_button("Activate token",
|
||||
func(): AccessService.activate_token(token)))
|
||||
|
||||
|
||||
func _render(value: Dictionary) -> void:
|
||||
if summary == null:
|
||||
return
|
||||
var credits := int(value.get("credits", 0))
|
||||
var unlimited := bool(value.get("unlimited", false))
|
||||
var expiry := str(value.get("unlimitedUntilUtc", ""))
|
||||
summary.text = "%d credits • %s" % [
|
||||
credits,
|
||||
("Unlimited QBank active until %s" % expiry) if unlimited else "Free QBank access"
|
||||
]
|
||||
quota.text = "%d of %d free questions remaining in the rolling window" % [
|
||||
int(value.get("questionsRemaining", 20)),
|
||||
int(value.get("freeQuestionLimit", 20))
|
||||
]
|
||||
token_labels.get("day", _label()).text = "%d available" % int(value.get("dayTokens", 0))
|
||||
token_labels.get("week", _label()).text = "%d available" % int(value.get("weekTokens", 0))
|
||||
token_labels.get("month", _label()).text = "%d available" % int(value.get("monthTokens", 0))
|
||||
token_labels.get("exam", _label()).text = "Exam tokens: %d" % int(value.get("examTokens", 0))
|
||||
token_labels.get("reset", _label()).text = "QBank reset tokens: %d" % int(value.get("resetTokens", 0))
|
||||
|
||||
|
||||
func _confirm_reset() -> void:
|
||||
reset_dialog.popup_centered(Vector2i(620, 260))
|
||||
|
||||
|
||||
func _on_success(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
|
||||
|
||||
|
||||
func _on_failure(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Color("ef4444"))
|
||||
|
||||
|
||||
func _on_checkout_started(_purchase_id: String, _url: String) -> void:
|
||||
status_label.text = "Complete checkout in the secure payment window."
|
||||
|
||||
|
||||
func refresh_theme_controls() -> void:
|
||||
for button in action_buttons:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
button.add_theme_font_override("font", Brand.font)
|
||||
button.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
|
||||
button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
|
||||
button.add_theme_stylebox_override("pressed", Brand.secondary_button_box(dark, true))
|
||||
|
||||
|
||||
func _panel() -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
return panel
|
||||
|
||||
|
||||
func _label(text := "", size := 16) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", size)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
return label
|
||||
|
||||
|
||||
func _button(text: String, callback: Callable) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = text
|
||||
button.custom_minimum_size = Vector2(0, 48)
|
||||
button.pressed.connect(callback)
|
||||
action_buttons.append(button)
|
||||
return button
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgkgwx6ds6cjn
|
||||
+18
-1
@@ -350,6 +350,11 @@ func _build_ui() -> void:
|
||||
donations.main = self
|
||||
screens_container.add_child(donations)
|
||||
screens["donations"] = donations
|
||||
var access_store := preload("res://scripts/ui/access_store_screen.gd").new()
|
||||
access_store.name = "AccessStoreScreen"
|
||||
access_store.main = self
|
||||
screens_container.add_child(access_store)
|
||||
screens["access_store"] = access_store
|
||||
|
||||
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||
feedback_dialog.name = "AppFeedbackDialog"
|
||||
@@ -369,10 +374,16 @@ func _build_ui() -> void:
|
||||
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
AuthState.session_expired.connect(_on_session_expired)
|
||||
QuestionBankService.access_required.connect(_on_qbank_access_required)
|
||||
_refresh_header_controls()
|
||||
_build_app_menu()
|
||||
|
||||
|
||||
func _on_qbank_access_required(_message: String) -> void:
|
||||
if AuthState.is_logged_in:
|
||||
show_screen("access_store")
|
||||
|
||||
|
||||
func _build_sidebar() -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.name = "Sidebar"
|
||||
@@ -426,6 +437,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
container.add_child(spacer)
|
||||
container.add_child(HSeparator.new())
|
||||
_add_nav_button(container, "access_store", "Credits & Access", "clock-3", true)
|
||||
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
|
||||
_add_nav_button(container, "performance", "Profile", "user-round", true)
|
||||
_add_nav_button(container, "settings", "Settings", "settings", true)
|
||||
@@ -471,6 +483,7 @@ func _on_navigation_pressed(key: String) -> void:
|
||||
"notes": show_screen("notes")
|
||||
"bookmarks": show_screen("bookmarks")
|
||||
"donations": show_screen("donations")
|
||||
"access_store": show_screen("access_store")
|
||||
"logout": _logout()
|
||||
|
||||
|
||||
@@ -489,7 +502,8 @@ func _update_header_title(screen_name: String) -> void:
|
||||
"settings": "Settings",
|
||||
"notes": "Notes",
|
||||
"bookmarks": "Bookmarks",
|
||||
"donations": "Support RanaZe"
|
||||
"donations": "Support RanaZe",
|
||||
"access_store": "Credits & Access"
|
||||
}
|
||||
header_title.text = str(titles.get(screen_name, ""))
|
||||
|
||||
@@ -814,6 +828,9 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
|
||||
var donations = screens.get("donations")
|
||||
if donations != null and donations.has_method("refresh_theme_controls"):
|
||||
donations.refresh_theme_controls()
|
||||
var access_store = screens.get("access_store")
|
||||
if access_store != null and access_store.has_method("refresh_theme_controls"):
|
||||
access_store.refresh_theme_controls()
|
||||
var player = screens.get("player")
|
||||
if player != null and player.has_method("refresh_theme_controls"):
|
||||
player.refresh_theme_controls()
|
||||
|
||||
@@ -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 == "access_store":
|
||||
key = "access_store"
|
||||
return key
|
||||
|
||||
@@ -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 AccessService.status_changed.is_connected(_on_access_changed):
|
||||
AccessService.status_changed.connect(_on_access_changed)
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
@@ -147,6 +149,7 @@ func on_show(_payload: Variant = null) -> void:
|
||||
load_more_button.disabled = true
|
||||
ExamSimulationService.load_readiness()
|
||||
ExamSimulationService.load_active()
|
||||
AccessService.refresh()
|
||||
_load_history_page()
|
||||
|
||||
|
||||
@@ -223,7 +226,8 @@ func _render_active_section() -> void:
|
||||
card_layout.add_child(actions)
|
||||
var primary := Button.new()
|
||||
primary.name = "PrimarySimulationAction"
|
||||
primary.text = "Resume Simulation" if active else "Start Simulation"
|
||||
var has_exam_token := int(AccessService.status.get("examTokens", 0)) > 0
|
||||
primary.text = "Resume Simulation" if active else ("Start Simulation" if has_exam_token else "Get Exam Token")
|
||||
primary.custom_minimum_size = Vector2(0, 48)
|
||||
primary.disabled = pending_launch or (not active and not bool(ExamSimulationService.readiness.get("ready", false)))
|
||||
_style_gradient_button(primary)
|
||||
@@ -452,6 +456,7 @@ func _on_simulation_loaded() -> void:
|
||||
return
|
||||
if pending_launch:
|
||||
pending_launch = false
|
||||
AccessService.refresh()
|
||||
_route_loaded_simulation()
|
||||
return
|
||||
if not pending_simulation_id.is_empty():
|
||||
@@ -483,6 +488,9 @@ func _route_loaded_simulation() -> void:
|
||||
|
||||
|
||||
func _launch_or_resume() -> void:
|
||||
if not _has_active_simulation() and int(AccessService.status.get("examTokens", 0)) <= 0:
|
||||
main.show_screen("access_store")
|
||||
return
|
||||
pending_launch = true
|
||||
pending_simulation_id = ""
|
||||
_render_active_section()
|
||||
@@ -538,6 +546,11 @@ func _on_request_failed(message: String) -> void:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _on_access_changed(_status: Dictionary) -> void:
|
||||
if visible:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
var use_compact := size.x < 940
|
||||
if compact_layout == use_compact:
|
||||
@@ -564,7 +577,8 @@ func _active_history_summary() -> Dictionary:
|
||||
|
||||
func _readiness_message() -> String:
|
||||
if bool(ExamSimulationService.readiness.get("ready", false)):
|
||||
return "The question bank is ready. Start when you have enough uninterrupted time for a full simulation."
|
||||
var tokens := int(AccessService.status.get("examTokens", 0))
|
||||
return "Exam tokens available: %d. One token is consumed when a new simulation is created." % tokens
|
||||
var available := int(ExamSimulationService.readiness.get("availableQuestions", 0))
|
||||
var required := int(ExamSimulationService.readiness.get("requiredQuestions", 232))
|
||||
return "Simulation unlocks when %d published questions are available. Currently %d/%d." % [required, available, required]
|
||||
|
||||
Reference in New Issue
Block a user