From 4bbac27b84e088839a09ca4725317bfea41a5754 Mon Sep 17 00:00:00 2001 From: hz Date: Mon, 27 Jul 2026 22:20:21 -0500 Subject: [PATCH] Add freemium access economy and token store --- .gitea/scripts/deploy-environment.sh | 2 + .gitea/workflows/deploy-prd.yml | 1 + .gitea/workflows/deploy-uat.yml | 1 + ENVIRONMENTS.md | 19 +- README.md | 5 + api/QuestionsApi.Tests/AccessCatalogTests.cs | 30 ++ .../Controllers/AccessController.cs | 71 +++++ .../Controllers/AccessPurchasesController.cs | 106 +++++++ .../Controllers/ExamSimulationsController.cs | 20 +- .../Controllers/QuestionsController.cs | 54 +++- api/QuestionsApi/Models/AccessModels.cs | 146 ++++++++++ api/QuestionsApi/Models/QuestionAttempt.cs | 2 + api/QuestionsApi/Models/QuestionRequests.cs | 1 + api/QuestionsApi/Models/QuestionResponse.cs | 2 + api/QuestionsApi/Program.cs | 4 + .../Services/AccessPurchaseStore.cs | 96 ++++++ api/QuestionsApi/Services/AccessStore.cs | 258 ++++++++++++++++ api/QuestionsApi/Services/DashboardStore.cs | 9 +- .../Services/QuestionAttemptStore.cs | 27 +- api/QuestionsApi/Services/RatingStore.cs | 12 + .../Services/StripeAccessGateway.cs | 63 ++++ api/QuestionsApi/Services/UserProfileStore.cs | 3 +- api/QuestionsApi/appsettings.Development.json | 4 +- api/QuestionsApi/appsettings.json | 4 +- docs/ACCESS_ECONOMY.md | 82 ++++++ docs/STEAM_ALPHA_RELEASE_CHECKLIST.md | 275 ++++++++++++++++++ docs/STRIPE_DONATIONS.md | 19 +- export_presets.cfg | 2 +- k8s/base/api-deployment.yaml | 5 + k8s/overlays/prd/kustomization.yaml | 2 + k8s/overlays/uat/kustomization.yaml | 2 + project.godot | 2 + scripts/autoload/access_service.gd | 249 ++++++++++++++++ scripts/autoload/access_service.gd.uid | 1 + scripts/autoload/question_bank_service.gd | 35 ++- scripts/ui/access_store_screen.gd | 190 ++++++++++++ scripts/ui/access_store_screen.gd.uid | 1 + scripts/ui/main.gd | 19 +- scripts/ui/navigation_routes.gd | 2 + scripts/ui/simulation_history.gd | 18 +- 40 files changed, 1807 insertions(+), 37 deletions(-) create mode 100644 api/QuestionsApi.Tests/AccessCatalogTests.cs create mode 100644 api/QuestionsApi/Controllers/AccessController.cs create mode 100644 api/QuestionsApi/Controllers/AccessPurchasesController.cs create mode 100644 api/QuestionsApi/Models/AccessModels.cs create mode 100644 api/QuestionsApi/Services/AccessPurchaseStore.cs create mode 100644 api/QuestionsApi/Services/AccessStore.cs create mode 100644 api/QuestionsApi/Services/StripeAccessGateway.cs create mode 100644 docs/ACCESS_ECONOMY.md create mode 100644 docs/STEAM_ALPHA_RELEASE_CHECKLIST.md create mode 100644 scripts/autoload/access_service.gd create mode 100644 scripts/autoload/access_service.gd.uid create mode 100644 scripts/ui/access_store_screen.gd create mode 100644 scripts/ui/access_store_screen.gd.uid diff --git a/.gitea/scripts/deploy-environment.sh b/.gitea/scripts/deploy-environment.sh index 86ddd43..9c23288 100644 --- a/.gitea/scripts/deploy-environment.sh +++ b/.gitea/scripts/deploy-environment.sh @@ -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 - diff --git a/.gitea/workflows/deploy-prd.yml b/.gitea/workflows/deploy-prd.yml index 951ea2e..794e4e2 100644 --- a/.gitea/workflows/deploy-prd.yml +++ b/.gitea/workflows/deploy-prd.yml @@ -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 diff --git a/.gitea/workflows/deploy-uat.yml b/.gitea/workflows/deploy-uat.yml index f7f0e7d..bbb633f 100644 --- a/.gitea/workflows/deploy-uat.yml +++ b/.gitea/workflows/deploy-uat.yml @@ -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 diff --git a/ENVIRONMENTS.md b/ENVIRONMENTS.md index 5e07be0..79187b7 100644 --- a/ENVIRONMENTS.md +++ b/ENVIRONMENTS.md @@ -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 diff --git a/README.md b/README.md index e1c1673..7cc45a4 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/api/QuestionsApi.Tests/AccessCatalogTests.cs b/api/QuestionsApi.Tests/AccessCatalogTests.cs new file mode 100644 index 0000000..fb6ce55 --- /dev/null +++ b/api/QuestionsApi.Tests/AccessCatalogTests.cs @@ -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); + } +} diff --git a/api/QuestionsApi/Controllers/AccessController.cs b/api/QuestionsApi/Controllers/AccessController.cs new file mode 100644 index 0000000..d67779f --- /dev/null +++ b/api/QuestionsApi/Controllers/AccessController.cs @@ -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 Get() => + Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER"))); + + [HttpPost("redeem-credits")] + public async Task 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 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 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; +} diff --git a/api/QuestionsApi/Controllers/AccessPurchasesController.cs b/api/QuestionsApi/Controllers/AccessPurchasesController.cs new file mode 100644 index 0000000..c7c3f53 --- /dev/null +++ b/api/QuestionsApi/Controllers/AccessPurchasesController.cs @@ -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 logger) : ControllerBase +{ + [HttpPost("web-checkouts")] + [Authorize(Roles = "USER,SUPER")] + public async Task 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 GetPurchase(string id) => + await purchases.GetOwnedAsync(id.Trim(), UserId()) is { } purchase + ? Ok(AccessPurchaseStore.ToStatus(purchase)) + : NotFound(); + + [HttpPost("stripe-webhook")] + [AllowAnonymous] + public async Task 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(); +} diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs index 60d52be..d2c71f5 100644 --- a/api/QuestionsApi/Controllers/ExamSimulationsController.cs +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -9,7 +9,7 @@ namespace QuestionsApi.Controllers; [ApiController] [Route("api/exam-simulations")] [Authorize(Roles = "USER,SUPER")] -public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase +public class ExamSimulationsController(ExamSimulationStore simulations, AccessStore access) : ControllerBase { [HttpGet("readiness")] public async Task Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); @@ -17,12 +17,28 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro [HttpPost] public async Task 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")] diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 221be3d..113c8ee 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -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 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 Skip(string externalId) + public async Task 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")) }); } diff --git a/api/QuestionsApi/Models/AccessModels.cs b/api/QuestionsApi/Models/AccessModels.cs new file mode 100644 index 0000000..6931f7b --- /dev/null +++ b/api/QuestionsApi/Models/AccessModels.cs @@ -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 Products = + new Dictionary(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 RecentUsage { get; set; } = []; + public List RecentCredits { get; set; } = []; + public List 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 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 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; } = ""; +} diff --git a/api/QuestionsApi/Models/QuestionAttempt.cs b/api/QuestionsApi/Models/QuestionAttempt.cs index 691c708..a05d750 100644 --- a/api/QuestionsApi/Models/QuestionAttempt.cs +++ b/api/QuestionsApi/Models/QuestionAttempt.cs @@ -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 PresentedChoiceIds { get; set; } = []; public bool TracksPresentation { get; set; } diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 7004cef..8e075a7 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -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 PresentedChoiceIds { get; set; } = []; diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 0c636ba..471572a 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -141,6 +141,7 @@ public class FirstQuestionAttemptResponse public Dictionary 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 ChoiceSelectionPercentages { get; set; } = []; public int QuestionElo { get; set; } = 1000; public RatingChangeResponse? Rating { get; set; } + public AccessStatusResponse? Access { get; set; } } public static class RatingCategory diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index c04924b..815d8fe 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -66,6 +66,10 @@ builder.Services.Configure(options => }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/Services/AccessPurchaseStore.cs b/api/QuestionsApi/Services/AccessPurchaseStore.cs new file mode 100644 index 0000000..64f4b71 --- /dev/null +++ b/api/QuestionsApi/Services/AccessPurchaseStore.cs @@ -0,0 +1,96 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class AccessPurchaseStore +{ + private readonly IMongoCollection _purchases; + + public AccessPurchaseStore(MongoDatabaseProvider mongo) + { + _purchases = mongo.Database.GetCollection("AccessPurchases"); + _purchases.Indexes.CreateMany([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)), + new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.ProviderSessionId), + new CreateIndexOptions + { + Unique = true, + PartialFilterExpression = Builders.Filter.Ne(value => value.ProviderSessionId, "") + }) + ]); + } + + public async Task 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 GetOwnedAsync(string id, string userId) => + await _purchases.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync(); + + public async Task 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.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.Update + .Set(value => value.Status, AccessPurchaseStatus.Failed) + .Set(value => value.UpdatedUtc, DateTime.UtcNow)); + + public async Task 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.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.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 { ReturnDocument = ReturnDocument.After }); + } + + public Task MarkGrantAppliedAsync(string id) => + _purchases.UpdateOneAsync( + value => value.Id == id && value.Status == AccessPurchaseStatus.Paid, + Builders.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 + }; +} diff --git a/api/QuestionsApi/Services/AccessStore.cs b/api/QuestionsApi/Services/AccessStore.cs new file mode 100644 index 0000000..6dade35 --- /dev/null +++ b/api/QuestionsApi/Services/AccessStore.cs @@ -0,0 +1,258 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class AccessStore +{ + private readonly IMongoCollection _accounts; + private readonly TimeProvider _time; + + public AccessStore(MongoDatabaseProvider mongo, TimeProvider time) + { + _accounts = mongo.Database.GetCollection("UserAccessAccounts"); + _time = time; + _accounts.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId), + new CreateIndexOptions { Unique = true, Name = "uq_access_user" })); + } + + public async Task 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 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 CanLoadQuestionAsync(string userId, bool bypass) + { + if (bypass) return true; + var status = await GetStatusAsync(userId); + return status.Unlimited || status.QuestionsRemaining > 0; + } + + public async Task 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.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(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.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 TryAwardCreditAsync(string userId, string operationId) + { + var now = _time.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var filters = Builders.Filter; + var capacity = new BsonDocumentFilterDefinition(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.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 RedeemCreditsAsync(string userId, string operationId) => + SpendAndExtendAsync(userId, operationId, AccessCatalog.DayPassCreditCost, null, TimeSpan.FromHours(24)); + + public Task 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(null) + }; + + public async Task TryConsumeExamTokenAsync(string userId, string operationId) + { + var now = _time.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var filters = Builders.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.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.Filter.Eq(value => value.UserId, userId) & + Builders.Filter.AnyEq(value => value.AppliedOperationIds, operationId); + await _accounts.UpdateOneAsync(filter, Builders.Update + .Inc(value => value.ExamTokens, 1) + .Pull(value => value.AppliedOperationIds, operationId) + .Set(value => value.UpdatedUtc, now)); + } + + public async Task ApplyResetAsync(string userId, string operationId) + { + var now = _time.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var filters = Builders.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.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 { ReturnDocument = ReturnDocument.After }); + return updated?.BankCycle; + } + + public async Task ApplyProductGrantAsync(string userId, AccessProduct product, string operationId) + { + var now = _time.GetUtcNow().UtcDateTime; + await EnsureAndCleanAsync(userId, now); + var filters = Builders.Filter; + var filter = filters.Eq(value => value.UserId, userId) & + filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)); + var update = Builders.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 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.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.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.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.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.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() + }; + } +} diff --git a/api/QuestionsApi/Services/DashboardStore.cs b/api/QuestionsApi/Services/DashboardStore.cs index 3cb77de..d3e4e5b 100644 --- a/api/QuestionsApi/Services/DashboardStore.cs +++ b/api/QuestionsApi/Services/DashboardStore.cs @@ -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 _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("QuestionAttempts"); _ccsRatings = mongo.Database.GetCollection("CcsUserRatings"); @@ -65,6 +67,7 @@ public sealed class DashboardStore _flashcardSchedules = flashcardSchedules; _ratingStore = ratingStore; _ccsRatingStore = ccsRatingStore; + _accessStore = accessStore; } public async Task 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(); diff --git a/api/QuestionsApi/Services/QuestionAttemptStore.cs b/api/QuestionsApi/Services/QuestionAttemptStore.cs index c2921d2..f412887 100644 --- a/api/QuestionsApi/Services/QuestionAttemptStore.cs +++ b/api/QuestionsApi/Services/QuestionAttemptStore.cs @@ -15,12 +15,20 @@ public class QuestionAttemptStore { _col = mongo.Database.GetCollection("QuestionAttempts"); + try + { + _col.Indexes.DropOne("QuestionExternalId_1_UserId_1"); + } + catch (MongoCommandException exception) when (exception.Code is 26 or 27) + { + } var firstAttemptIndex = Builders.IndexKeys .Ascending(a => a.QuestionExternalId) - .Ascending(a => a.UserId); + .Ascending(a => a.UserId) + .Ascending(a => a.BankCycle); _col.Indexes.CreateOne(new CreateIndexModel( firstAttemptIndex, - new CreateIndexOptions { Unique = true })); + new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" })); var questionIndex = Builders.IndexKeys.Ascending(a => a.QuestionExternalId); _col.Indexes.CreateOne(new CreateIndexModel(questionIndex)); @@ -38,12 +46,16 @@ public class QuestionAttemptStore bool isCorrect, bool isSkip = false, IReadOnlyCollection? 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.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc)) .ToListAsync(); - public Task> GetUserQuestionIdsAsync(string userId) => + public Task> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) => _col.Distinct( attempt => attempt.QuestionExternalId, - Builders.Filter.Eq(attempt => attempt.UserId, userId)) + Builders.Filter.Eq(attempt => attempt.UserId, userId) & + (bankCycle is null + ? FilterDefinition.Empty + : Builders.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value))) .ToListAsync(); public IMongoCollection Collection => _col; diff --git a/api/QuestionsApi/Services/RatingStore.cs b/api/QuestionsApi/Services/RatingStore.cs index 0d3d790..8fbb795 100644 --- a/api/QuestionsApi/Services/RatingStore.cs +++ b/api/QuestionsApi/Services/RatingStore.cs @@ -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.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> GetQuestionElosAsync(IEnumerable externalIds) { var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList(); diff --git a/api/QuestionsApi/Services/StripeAccessGateway.cs b/api/QuestionsApi/Services/StripeAccessGateway.cs new file mode 100644 index 0000000..c2167a6 --- /dev/null +++ b/api/QuestionsApi/Services/StripeAccessGateway.cs @@ -0,0 +1,63 @@ +using QuestionsApi.Models; +using Stripe; +using Stripe.Checkout; + +namespace QuestionsApi.Services; + +public interface IStripeAccessGateway +{ + Task CreateCheckoutAsync(AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken); +} + +public sealed class StripeAccessGateway(IConfiguration configuration) : IStripeAccessGateway +{ + public async Task 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 + { + ["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); + } +} diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 2e3c086..7b87044 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -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) diff --git a/api/QuestionsApi/appsettings.Development.json b/api/QuestionsApi/appsettings.Development.json index 2574885..e6ab07e 100644 --- a/api/QuestionsApi/appsettings.Development.json +++ b/api/QuestionsApi/appsettings.Development.json @@ -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": { diff --git a/api/QuestionsApi/appsettings.json b/api/QuestionsApi/appsettings.json index 2c9d234..fb65888 100644 --- a/api/QuestionsApi/appsettings.json +++ b/api/QuestionsApi/appsettings.json @@ -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", diff --git a/docs/ACCESS_ECONOMY.md b/docs/ACCESS_ECONOMY.md new file mode 100644 index 0000000..b8a1003 --- /dev/null +++ b/docs/ACCESS_ECONOMY.md @@ -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. diff --git a/docs/STEAM_ALPHA_RELEASE_CHECKLIST.md b/docs/STEAM_ALPHA_RELEASE_CHECKLIST.md new file mode 100644 index 0000000..63f5206 --- /dev/null +++ b/docs/STEAM_ALPHA_RELEASE_CHECKLIST.md @@ -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 `support@ranaze.com` 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) diff --git a/docs/STRIPE_DONATIONS.md b/docs/STRIPE_DONATIONS.md index d41757f..282cc48 100644 --- a/docs/STRIPE_DONATIONS.md +++ b/docs/STRIPE_DONATIONS.md @@ -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. diff --git a/export_presets.cfg b/export_presets.cfg index af03f14..5f10c2c 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -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="" diff --git a/k8s/base/api-deployment.yaml b/k8s/base/api-deployment.yaml index 405b377..04b64c7 100644 --- a/k8s/base/api-deployment.yaml +++ b/k8s/base/api-deployment.yaml @@ -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: diff --git a/k8s/overlays/prd/kustomization.yaml b/k8s/overlays/prd/kustomization.yaml index 4610045..dda2238 100644 --- a/k8s/overlays/prd/kustomization.yaml +++ b/k8s/overlays/prd/kustomization.yaml @@ -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 diff --git a/k8s/overlays/uat/kustomization.yaml b/k8s/overlays/uat/kustomization.yaml index b4d0cd2..7afcd41 100644 --- a/k8s/overlays/uat/kustomization.yaml +++ b/k8s/overlays/uat/kustomization.yaml @@ -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 diff --git a/project.godot b/project.godot index 122d1a6..2264652 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/scripts/autoload/access_service.gd b/scripts/autoload/access_service.gd new file mode 100644 index 0000000..1f4d366 --- /dev/null +++ b/scripts/autoload/access_service.gd @@ -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) diff --git a/scripts/autoload/access_service.gd.uid b/scripts/autoload/access_service.gd.uid new file mode 100644 index 0000000..de5ff91 --- /dev/null +++ b/scripts/autoload/access_service.gd.uid @@ -0,0 +1 @@ +uid://ch2scupdmgjpj diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 9e4d369..add7a78 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -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", ""))) diff --git a/scripts/ui/access_store_screen.gd b/scripts/ui/access_store_screen.gd new file mode 100644 index 0000000..0ff0276 --- /dev/null +++ b/scripts/ui/access_store_screen.gd @@ -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 diff --git a/scripts/ui/access_store_screen.gd.uid b/scripts/ui/access_store_screen.gd.uid new file mode 100644 index 0000000..5bf4b7f --- /dev/null +++ b/scripts/ui/access_store_screen.gd.uid @@ -0,0 +1 @@ +uid://bgkgwx6ds6cjn diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index dbbdc5f..1402196 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -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() diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index 8070cec..99eb727 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -29,4 +29,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri key = "bookmarks" elif screen_name == "donations": key = "donations" + elif screen_name == "access_store": + key = "access_store" return key diff --git a/scripts/ui/simulation_history.gd b/scripts/ui/simulation_history.gd index 48a733a..d546799 100644 --- a/scripts/ui/simulation_history.gd +++ b/scripts/ui/simulation_history.gd @@ -137,6 +137,8 @@ func _connect_services() -> void: ExamSimulationService.state_changed.connect(_on_simulation_loaded) if not ExamSimulationService.request_failed.is_connected(_on_request_failed): ExamSimulationService.request_failed.connect(_on_request_failed) + if not 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]