Author SHA1 Message Date
hz 4bbac27b84 Add freemium access economy and token store 2026-07-27 22:20:21 -05:00
64 changed files with 1856 additions and 1100 deletions
+2
View File
@@ -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 -
+1
View File
@@ -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
+1
View File
@@ -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
+16 -5
View File
@@ -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,11 +77,12 @@ 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
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. Any promoted question containing media is flagged again in PRD and remains hidden from students until a SUPER user explicitly approves it in production. UAT media approval is not treated as production approval. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
+5
View File
@@ -4,6 +4,11 @@ Environment deployment, database migration, and UAT-to-PRD promotion procedures
Godot-based USMLE Step 3 study trainer with a bundled ASP.NET Core Questions API.
The gated process for publishing the first Windows alpha through Steam is in
[docs/STEAM_ALPHA_RELEASE_CHECKLIST.md](docs/STEAM_ALPHA_RELEASE_CHECKLIST.md).
The free-access limits, earned-credit rules, paid tokens, and checkout setup are
documented in [docs/ACCESS_ECONOMY.md](docs/ACCESS_ECONOMY.md).
## Local API
Run the Questions API:
@@ -0,0 +1,30 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class AccessCatalogTests
{
[Fact]
public void FreeEconomy_HasTheReleaseLimits()
{
Assert.Equal(20, AccessCatalog.FreeQuestionLimit);
Assert.Equal(20, AccessCatalog.CreditEarningLimit);
Assert.Equal(100, AccessCatalog.DayPassCreditCost);
}
[Theory]
[InlineData("access_day_1", 100, AccessGrant.DayToken)]
[InlineData("access_week_1", 500, AccessGrant.WeekToken)]
[InlineData("access_month_1", 2000, AccessGrant.MonthToken)]
[InlineData("exam_token_1", 199, AccessGrant.ExamToken)]
[InlineData("qbank_reset_1", 499, AccessGrant.ResetToken)]
public void Products_HaveServerControlledPrices(string sku, long cents, string grant)
{
var product = AccessCatalog.Products[sku];
Assert.Equal(cents, product.AmountCents);
Assert.Equal("usd", product.Currency);
Assert.Equal(grant, product.Grant);
}
}
-39
View File
@@ -148,45 +148,6 @@ public sealed class ChoicePoolTests
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void PublishedQuestionRequiresCompleteLabReferenceRanges()
{
var request = BuildRequest(5);
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Contains("requires a reference range", QuestionValidator.Validate(request));
request.Labs[0].Reference = "136–145 mEq/L";
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void DraftQuestionMaySaveLabWithoutReferenceRange()
{
var request = BuildRequest(5);
request.IsPublished = false;
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void LabEntriesAlwaysRequireNamesAndValues()
{
var request = BuildRequest(5);
request.Labs = [new QuestionLab { Name = "", Value = "128", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
request.Labs = [new QuestionLab { Name = "Sodium", Value = "", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
}
[Theory]
[InlineData("http://example.org/guideline", "HTTPS")]
[InlineData("https://example.org/guideline", null)]
@@ -49,44 +49,6 @@ public sealed class ContentPromotionTests
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
}
[Fact]
public void ProductionPromotionFlagsMediaForSeparateApproval()
{
var updated = new DateTime(2026, 7, 30, 12, 0, 0, DateTimeKind.Utc);
var source = new BsonDocument
{
["ExternalId"] = "media-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["UpdatedUtc"] = updated,
["Media"] = new BsonArray { ObjectId.GenerateNewId().ToString() }
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.Equal(updated, prepared["FlaggedForReviewUtc"].ToUniversalTime());
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewReason, prepared["FlagReason"].AsString);
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewActor, prepared["FlaggedByUserId"].AsString);
Assert.True(source["FlaggedForReviewUtc"].IsBsonNull);
}
[Fact]
public void ProductionPromotionLeavesMediaFreeQuestionApprovalStateAlone()
{
var source = new BsonDocument
{
["ExternalId"] = "text-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["Media"] = new BsonArray()
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.True(prepared["FlaggedForReviewUtc"].IsBsonNull);
Assert.False(prepared.Contains("FlagReason"));
}
[Fact]
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
{
@@ -1,21 +0,0 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class CreditEconomyTests
{
[Fact]
public void EarningPolicy_UsesTheAgreedRollingLimit()
{
Assert.Equal(20, CreditEconomy.RollingEarningLimit);
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow);
}
[Fact]
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
{
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings)));
}
}
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/access")]
[Authorize(Roles = "USER,SUPER")]
public sealed class AccessController(
AccessStore access,
ExamSimulationStore simulations,
RatingStore ratings,
TimeProvider clock) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get() =>
Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
[HttpPost("redeem-credits")]
public async Task<IActionResult> RedeemCredits(RedeemCreditsRequest request)
{
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
var status = await access.RedeemCreditsAsync(UserId(), request.OperationId.Trim());
return status is null
? Conflict(new { code = "insufficient_credits", message = "100 credits are required to redeem a day pass." })
: Ok(status);
}
[HttpPost("activate-token")]
public async Task<IActionResult> ActivateToken(ActivateAccessTokenRequest request)
{
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
if (request.Token.Trim().ToLowerInvariant() is not ("day" or "week" or "month"))
return BadRequest("token must be day, week, or month");
var status = await access.ActivateTokenAsync(UserId(), request.OperationId.Trim(), request.Token);
return status is null
? Conflict(new { code = "token_unavailable", message = "That access token is not available." })
: Ok(status);
}
[HttpPost("qbank-reset")]
public async Task<IActionResult> ResetQBank(ResetQBankRequest request)
{
if (request.Confirmation != "RESET_QBANK") return BadRequest("Type RESET_QBANK to confirm.");
if (!ValidOperationId(request.OperationId)) return BadRequest("A valid operationId is required.");
if (await simulations.FindActiveAsync(UserId()) is not null)
return Conflict(new { code = "active_exam", message = "Finish or abandon the active exam simulation before resetting the QBank." });
var cycle = await access.ApplyResetAsync(UserId(), request.OperationId.Trim());
if (cycle is null)
return Conflict(new { code = "reset_token_unavailable", message = "A QBank reset token is required." });
await ratings.ResetUserAsync(UserId(), clock.GetUtcNow().UtcDateTime);
return Ok(await access.GetStatusAsync(UserId(), User.IsInRole("SUPER")));
}
[HttpPost("steam-checkouts")]
public IActionResult SteamCheckout() => Problem(
statusCode: StatusCodes.Status503ServiceUnavailable,
title: "Steam Wallet checkout is not configured",
detail: "Steam purchases will be enabled after the Steamworks application and product catalog are configured.");
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
private static bool ValidOperationId(string value) =>
!string.IsNullOrWhiteSpace(value) && value.Trim().Length <= 100;
}
@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using Stripe;
using Stripe.Checkout;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/access")]
public sealed class AccessPurchasesController(
AccessPurchaseStore purchases,
AccessStore access,
IStripeAccessGateway stripe,
IConfiguration configuration,
ILogger<AccessPurchasesController> logger) : ControllerBase
{
[HttpPost("web-checkouts")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> CreateCheckout(CreateAccessCheckoutRequest request, CancellationToken cancellationToken)
{
if (!AccessCatalog.Products.TryGetValue(request.Sku.Trim(), out var product))
return BadRequest("Unknown access product.");
var purchase = await purchases.CreateAsync(UserId(), product);
try
{
var checkout = await stripe.CreateCheckoutAsync(purchase, product, cancellationToken);
await purchases.AttachSessionAsync(purchase.Id!, checkout.SessionId);
return StatusCode(StatusCodes.Status201Created, new AccessCheckoutResponse
{
PurchaseId = purchase.Id!,
CheckoutUrl = checkout.CheckoutUrl,
Sku = product.Sku,
AmountCents = product.AmountCents
});
}
catch (Exception exception)
{
await purchases.MarkCheckoutFailedAsync(purchase.Id!);
logger.LogError(exception, "Unable to create access checkout {PurchaseId}", purchase.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway,
title: "Access checkout unavailable",
detail: "The secure checkout could not be started. Please try again.");
}
}
[HttpGet("purchases/{id}")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> GetPurchase(string id) =>
await purchases.GetOwnedAsync(id.Trim(), UserId()) is { } purchase
? Ok(AccessPurchaseStore.ToStatus(purchase))
: NotFound();
[HttpPost("stripe-webhook")]
[AllowAnonymous]
public async Task<IActionResult> StripeWebhook(CancellationToken cancellationToken)
{
var webhookSecret = configuration["Stripe:AccessWebhookSecret"];
if (string.IsNullOrWhiteSpace(webhookSecret))
return Problem(statusCode: 503, title: "Stripe webhook is not configured");
using var reader = new StreamReader(Request.Body);
var json = await reader.ReadToEndAsync(cancellationToken);
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"], webhookSecret);
}
catch (StripeException exception)
{
logger.LogWarning(exception, "Rejected access webhook with an invalid signature");
return BadRequest("Invalid Stripe signature");
}
if (stripeEvent.Data.Object is not Session session) return Ok();
var status = stripeEvent.Type switch
{
"checkout.session.completed" when session.PaymentStatus == "paid" => AccessPurchaseStatus.Paid,
"checkout.session.async_payment_succeeded" => AccessPurchaseStatus.Paid,
"checkout.session.async_payment_failed" => AccessPurchaseStatus.Failed,
"checkout.session.expired" => AccessPurchaseStatus.Expired,
_ => ""
};
if (status.Length == 0) return Ok();
var purchaseId = session.ClientReferenceId;
if (string.IsNullOrWhiteSpace(purchaseId) &&
session.Metadata.TryGetValue("access_purchase_id", out var metadataId))
purchaseId = metadataId;
var purchase = await purchases.ApplyProviderStatusAsync(
purchaseId ?? "", session.Id, session.AmountTotal ?? 0, session.Currency ?? "",
status, session.PaymentIntentId ?? "", stripeEvent.Id);
if (purchase is null) return Ok();
if (status == AccessPurchaseStatus.Paid &&
AccessCatalog.Products.TryGetValue(purchase.Sku, out var product))
{
await access.ApplyProductGrantAsync(purchase.UserId, product, $"purchase:{purchase.Id}");
await purchases.MarkGrantAppliedAsync(purchase.Id!);
}
return Ok();
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
@@ -1,20 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/credits")]
[Authorize(Roles = "USER,SUPER")]
public sealed class CreditsController(CreditStore credits) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
@@ -9,7 +9,7 @@ namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/exam-simulations")]
[Authorize(Roles = "USER,SUPER")]
public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
public class ExamSimulationsController(ExamSimulationStore simulations, AccessStore access) : ControllerBase
{
[HttpGet("readiness")]
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
@@ -17,12 +17,28 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
[HttpPost]
public async Task<IActionResult> Create()
{
var operationId = $"exam-create:{Guid.NewGuid():N}";
if (!User.IsInRole("SUPER") && !await access.TryConsumeExamTokenAsync(UserId(), operationId))
return StatusCode(StatusCodes.Status402PaymentRequired, new
{
code = "exam_token_required",
message = "An exam simulation token is required."
});
try
{
var simulation = await simulations.CreateAsync(UserId());
return Ok(await simulations.ToResponseAsync(simulation));
}
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
catch (InvalidOperationException exception)
{
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
return Conflict(exception.Message);
}
catch
{
if (!User.IsInRole("SUPER")) await access.RefundExamTokenAsync(UserId(), operationId);
throw;
}
}
[HttpGet("preview-results")]
@@ -19,7 +19,7 @@ public class QuestionsController : ControllerBase
private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits;
private readonly AccessStore _access;
public QuestionsController(
QuestionStore questions,
@@ -30,7 +30,7 @@ public class QuestionsController : ControllerBase
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue,
CreditStore credits)
AccessStore access)
{
_questions = questions;
_attempts = attempts;
@@ -40,7 +40,7 @@ public class QuestionsController : ControllerBase
_metrics = metrics;
_bookmarks = bookmarks;
_reviewQueue = reviewQueue;
_credits = credits;
_access = access;
}
[HttpGet("systems")]
@@ -55,6 +55,13 @@ public class QuestionsController : ControllerBase
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
{
if (!await _access.CanLoadQuestionAsync(UserId(), User.IsInRole("SUPER")))
return StatusCode(StatusCodes.Status403Forbidden, new
{
code = "qbank_limit_reached",
message = "Your free QBank allowance is exhausted. Redeem or activate an access pass to continue.",
access = await _access.GetStatusAsync(UserId())
});
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
@@ -120,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")))
@@ -149,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,
@@ -157,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();
@@ -175,8 +195,8 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate();
}
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit)
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
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(
@@ -210,7 +230,7 @@ public class QuestionsController : ControllerBase
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating,
Credits = await _credits.GetStatusAsync(userId)
Access = await _access.GetStatusAsync(userId, User.IsInRole("SUPER"))
});
}
@@ -258,7 +278,7 @@ public class QuestionsController : ControllerBase
[HttpPost("{externalId}/skip")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Skip(string externalId)
public async Task<IActionResult> Skip(string externalId, [FromQuery] string completionId)
{
if (string.IsNullOrWhiteSpace(externalId))
return BadRequest("externalId required");
@@ -270,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
@@ -310,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"))
});
}
+146
View File
@@ -0,0 +1,146 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class AccessCatalog
{
public const int FreeQuestionLimit = 20;
public const int CreditEarningLimit = 20;
public const int DayPassCreditCost = 100;
public static readonly IReadOnlyDictionary<string, AccessProduct> Products =
new Dictionary<string, AccessProduct>(StringComparer.Ordinal)
{
["access_day_1"] = new("access_day_1", "One-day QBank token", 100, "usd", AccessGrant.DayToken),
["access_week_1"] = new("access_week_1", "Seven-day QBank token", 500, "usd", AccessGrant.WeekToken),
["access_month_1"] = new("access_month_1", "Thirty-day QBank token", 2000, "usd", AccessGrant.MonthToken),
["exam_token_1"] = new("exam_token_1", "Exam simulation token", 199, "usd", AccessGrant.ExamToken),
["qbank_reset_1"] = new("qbank_reset_1", "QBank reset token", 499, "usd", AccessGrant.ResetToken)
};
}
public static class AccessGrant
{
public const string DayToken = "day_token";
public const string WeekToken = "week_token";
public const string MonthToken = "month_token";
public const string ExamToken = "exam_token";
public const string ResetToken = "reset_token";
}
public sealed record AccessProduct(string Sku, string Name, long AmountCents, string Currency, string Grant);
public sealed class AccessEvent
{
public string Id { get; set; } = "";
public string SubjectId { get; set; } = "";
public DateTime CreatedUtc { get; set; }
}
public sealed class UserAccessAccount
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public int Credits { get; set; }
public int DayTokens { get; set; }
public int WeekTokens { get; set; }
public int MonthTokens { get; set; }
public int ExamTokens { get; set; }
public int ResetTokens { get; set; }
public int BankCycle { get; set; }
public DateTime? UnlimitedUntilUtc { get; set; }
public List<AccessEvent> RecentUsage { get; set; } = [];
public List<AccessEvent> RecentCredits { get; set; } = [];
public List<string> AppliedOperationIds { get; set; } = [];
public int Revision { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class AccessStatusResponse
{
public int FreeQuestionLimit { get; set; } = AccessCatalog.FreeQuestionLimit;
public int QuestionsUsed { get; set; }
public int QuestionsRemaining { get; set; }
public DateTime? NextQuestionAvailableUtc { get; set; }
public int Credits { get; set; }
public int DayPassCreditCost { get; set; } = AccessCatalog.DayPassCreditCost;
public int DayTokens { get; set; }
public int WeekTokens { get; set; }
public int MonthTokens { get; set; }
public int ExamTokens { get; set; }
public int ResetTokens { get; set; }
public int BankCycle { get; set; }
public bool Unlimited { get; set; }
public DateTime? UnlimitedUntilUtc { get; set; }
public List<AccessProduct> Products { get; set; } = [];
}
public sealed class ActivateAccessTokenRequest
{
public string Token { get; set; } = "";
public string OperationId { get; set; } = "";
}
public sealed class RedeemCreditsRequest
{
public string OperationId { get; set; } = "";
}
public sealed class ResetQBankRequest
{
public string Confirmation { get; set; } = "";
public string OperationId { get; set; } = "";
}
public sealed class CreateAccessCheckoutRequest
{
public string Sku { get; set; } = "";
}
public sealed class AccessCheckoutResponse
{
public string PurchaseId { get; set; } = "";
public string CheckoutUrl { get; set; } = "";
public string Sku { get; set; } = "";
public long AmountCents { get; set; }
}
public static class AccessPurchaseStatus
{
public const string Pending = "pending";
public const string Paid = "paid";
public const string Failed = "failed";
public const string Expired = "expired";
public const string Refunded = "refunded";
}
public sealed class AccessPurchase
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public string Sku { get; set; } = "";
public long AmountCents { get; set; }
public string Currency { get; set; } = "usd";
public string Status { get; set; } = AccessPurchaseStatus.Pending;
public string Provider { get; set; } = "stripe";
public string ProviderSessionId { get; set; } = "";
public string ProviderPaymentId { get; set; } = "";
public List<string> ProviderEventIds { get; set; } = [];
public bool GrantApplied { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class AccessPurchaseStatusResponse
{
public string PurchaseId { get; set; } = "";
public string Sku { get; set; } = "";
public long AmountCents { get; set; }
public string Currency { get; set; } = "";
public string Status { get; set; } = "";
}
-35
View File
@@ -1,35 +0,0 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class CreditEconomy
{
public const int RollingEarningLimit = 20;
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
}
public sealed class CreditEvent
{
public string OperationId { get; set; } = "";
public DateTime CreatedUtc { get; set; }
}
public sealed class UserCreditAccount
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public int Credits { get; set; }
public List<CreditEvent> RecentEarnings { get; set; } = [];
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class CreditStatusResponse
{
public int Credits { get; set; }
public int EarnedInRollingWindow { get; set; }
public int RemainingEarningCapacity { get; set; }
public int RollingWindowHours { get; set; } = 24;
}
@@ -11,6 +11,8 @@ public class QuestionAttempt
public string QuestionExternalId { get; set; } = "";
public string UserId { get; set; } = "";
public int BankCycle { get; set; }
public string CompletionId { get; set; } = "";
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
public bool TracksPresentation { get; set; }
+1 -1
View File
@@ -45,9 +45,9 @@ public class FlagQuestionRequest
public class FirstQuestionAttemptRequest
{
public string CompletionId { get; set; } = "";
public string QuestionExternalId { get; set; } = "";
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
public int DurationSeconds { get; set; }
public bool MetTimeLimit { get; set; } = true;
}
+2 -1
View File
@@ -141,7 +141,7 @@ public class FirstQuestionAttemptResponse
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; }
public CreditStatusResponse? Credits { get; set; }
public AccessStatusResponse? Access { get; set; }
}
public class SkipQuestionResponse
@@ -151,6 +151,7 @@ public class SkipQuestionResponse
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; }
public AccessStatusResponse? Access { get; set; }
}
public static class RatingCategory
-2
View File
@@ -47,6 +47,4 @@ public class RatingProfileResponse
public int OverallElo { get; set; }
public int RatedQuestionCount { get; set; }
public Dictionary<string, int> CategoryElos { get; set; } = [];
public string Tier { get; set; } = "";
public int TierDivision { get; set; }
}
+3 -1
View File
@@ -67,7 +67,9 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CreditStore>();
builder.Services.AddSingleton<AccessStore>();
builder.Services.AddSingleton<AccessPurchaseStore>();
builder.Services.AddSingleton<IStripeAccessGateway, StripeAccessGateway>();
builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<AnswerStatisticsStore>();
@@ -0,0 +1,96 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class AccessPurchaseStore
{
private readonly IMongoCollection<AccessPurchase> _purchases;
public AccessPurchaseStore(MongoDatabaseProvider mongo)
{
_purchases = mongo.Database.GetCollection<AccessPurchase>("AccessPurchases");
_purchases.Indexes.CreateMany([
new CreateIndexModel<AccessPurchase>(
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)),
new CreateIndexModel<AccessPurchase>(
Builders<AccessPurchase>.IndexKeys.Ascending(value => value.ProviderSessionId),
new CreateIndexOptions<AccessPurchase>
{
Unique = true,
PartialFilterExpression = Builders<AccessPurchase>.Filter.Ne(value => value.ProviderSessionId, "")
})
]);
}
public async Task<AccessPurchase> CreateAsync(string userId, AccessProduct product)
{
var purchase = new AccessPurchase
{
UserId = userId,
Sku = product.Sku,
AmountCents = product.AmountCents,
Currency = product.Currency
};
await _purchases.InsertOneAsync(purchase);
return purchase;
}
public async Task<AccessPurchase?> GetOwnedAsync(string id, string userId) =>
await _purchases.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
public async Task<AccessPurchase?> GetAsync(string id) =>
await _purchases.Find(value => value.Id == id).FirstOrDefaultAsync();
public Task AttachSessionAsync(string id, string sessionId) =>
_purchases.UpdateOneAsync(
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
Builders<AccessPurchase>.Update
.Set(value => value.ProviderSessionId, sessionId)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
public Task MarkCheckoutFailedAsync(string id) =>
_purchases.UpdateOneAsync(
value => value.Id == id && value.Status == AccessPurchaseStatus.Pending,
Builders<AccessPurchase>.Update
.Set(value => value.Status, AccessPurchaseStatus.Failed)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
public async Task<AccessPurchase?> ApplyProviderStatusAsync(
string id, string sessionId, long amountCents, string currency,
string status, string paymentId, string eventId)
{
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(sessionId)) return null;
var filters = Builders<AccessPurchase>.Filter;
var filter = filters.Eq(value => value.Id, id) &
filters.Eq(value => value.AmountCents, amountCents) &
filters.Eq(value => value.Currency, currency.ToLowerInvariant()) &
(filters.Eq(value => value.ProviderSessionId, sessionId) |
filters.Eq(value => value.ProviderSessionId, "")) &
filters.Not(filters.AnyEq(value => value.ProviderEventIds, eventId));
var update = Builders<AccessPurchase>.Update
.Set(value => value.Status, status)
.Set(value => value.ProviderSessionId, sessionId)
.Set(value => value.ProviderPaymentId, paymentId)
.AddToSet(value => value.ProviderEventIds, eventId)
.Set(value => value.UpdatedUtc, DateTime.UtcNow);
return await _purchases.FindOneAndUpdateAsync(filter, update,
new FindOneAndUpdateOptions<AccessPurchase> { ReturnDocument = ReturnDocument.After });
}
public Task MarkGrantAppliedAsync(string id) =>
_purchases.UpdateOneAsync(
value => value.Id == id && value.Status == AccessPurchaseStatus.Paid,
Builders<AccessPurchase>.Update
.Set(value => value.GrantApplied, true)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
public static AccessPurchaseStatusResponse ToStatus(AccessPurchase purchase) => new()
{
PurchaseId = purchase.Id ?? "",
Sku = purchase.Sku,
AmountCents = purchase.AmountCents,
Currency = purchase.Currency,
Status = purchase.Status
};
}
+258
View File
@@ -0,0 +1,258 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class AccessStore
{
private readonly IMongoCollection<UserAccessAccount> _accounts;
private readonly TimeProvider _time;
public AccessStore(MongoDatabaseProvider mongo, TimeProvider time)
{
_accounts = mongo.Database.GetCollection<UserAccessAccount>("UserAccessAccounts");
_time = time;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserAccessAccount>(
Builders<UserAccessAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_access_user" }));
}
public async Task<AccessStatusResponse> GetStatusAsync(string userId, bool bypass = false)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return ToStatus(account, now, bypass);
}
public async Task<int> GetBankCycleAsync(string userId)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
}
public async Task<bool> CanLoadQuestionAsync(string userId, bool bypass)
{
if (bypass) return true;
var status = await GetStatusAsync(userId);
return status.Unlimited || status.QuestionsRemaining > 0;
}
public async Task<bool> ClaimQuestionAsync(string userId, string completionId, string questionExternalId, bool bypass)
{
if (bypass) return true;
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var exactClaim = filters.ElemMatch(value => value.RecentUsage,
item => item.Id == completionId && item.SubjectId == questionExternalId);
if (await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync())
return true;
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
{
new MongoDB.Bson.BsonDocument("$size", "$RecentUsage"),
AccessCatalog.FreeQuestionLimit
})));
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.ElemMatch(value => value.RecentUsage, item => item.Id == completionId)) &
(filters.Gt(value => value.UnlimitedUntilUtc, now) |
capacity);
var update = Builders<UserAccessAccount>.Update
.AddToSet(value => value.RecentUsage, new AccessEvent
{
Id = completionId,
SubjectId = questionExternalId,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1);
if ((await _accounts.UpdateOneAsync(filter, update)).MatchedCount == 1)
return true;
return await _accounts.Find(filters.Eq(value => value.UserId, userId) & exactClaim).AnyAsync();
}
public async Task<bool> TryAwardCreditAsync(string userId, string operationId)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var capacity = new BsonDocumentFilterDefinition<UserAccessAccount>(new MongoDB.Bson.BsonDocument(
"$expr", new MongoDB.Bson.BsonDocument("$lt", new MongoDB.Bson.BsonArray
{
new MongoDB.Bson.BsonDocument("$size", "$RecentCredits"),
AccessCatalog.CreditEarningLimit
})));
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId)) &
capacity;
var update = Builders<UserAccessAccount>.Update
.Inc(value => value.Credits, 1)
.AddToSet(value => value.AppliedOperationIds, operationId)
.Push(value => value.RecentCredits, new AccessEvent { Id = operationId, CreatedUtc = now })
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public Task<AccessStatusResponse?> RedeemCreditsAsync(string userId, string operationId) =>
SpendAndExtendAsync(userId, operationId, AccessCatalog.DayPassCreditCost, null, TimeSpan.FromHours(24));
public Task<AccessStatusResponse?> ActivateTokenAsync(string userId, string operationId, string token) =>
token.Trim().ToLowerInvariant() switch
{
"day" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.DayTokens), TimeSpan.FromHours(24)),
"week" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.WeekTokens), TimeSpan.FromHours(168)),
"month" => SpendAndExtendAsync(userId, operationId, 0, nameof(UserAccessAccount.MonthTokens), TimeSpan.FromHours(720)),
_ => Task.FromResult<AccessStatusResponse?>(null)
};
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var filter = filters.Eq(value => value.UserId, userId) &
filters.Gt(value => value.ExamTokens, 0) &
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
var update = Builders<UserAccessAccount>.Update
.Inc(value => value.ExamTokens, -1)
.AddToSet(value => value.AppliedOperationIds, operationId)
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task RefundExamTokenAsync(string userId, string operationId)
{
var now = _time.GetUtcNow().UtcDateTime;
var filter = Builders<UserAccessAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserAccessAccount>.Filter.AnyEq(value => value.AppliedOperationIds, operationId);
await _accounts.UpdateOneAsync(filter, Builders<UserAccessAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Pull(value => value.AppliedOperationIds, operationId)
.Set(value => value.UpdatedUtc, now));
}
public async Task<int?> ApplyResetAsync(string userId, string operationId)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var filter = filters.Eq(value => value.UserId, userId) &
filters.Gt(value => value.ResetTokens, 0) &
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
var updated = await _accounts.FindOneAndUpdateAsync(
filter,
Builders<UserAccessAccount>.Update
.Inc(value => value.ResetTokens, -1)
.Inc(value => value.BankCycle, 1)
.AddToSet(value => value.AppliedOperationIds, operationId)
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1),
new FindOneAndUpdateOptions<UserAccessAccount> { ReturnDocument = ReturnDocument.After });
return updated?.BankCycle;
}
public async Task<bool> ApplyProductGrantAsync(string userId, AccessProduct product, string operationId)
{
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
var update = Builders<UserAccessAccount>.Update
.AddToSet(value => value.AppliedOperationIds, operationId)
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1);
update = product.Grant switch
{
AccessGrant.DayToken => update.Inc(value => value.DayTokens, 1),
AccessGrant.WeekToken => update.Inc(value => value.WeekTokens, 1),
AccessGrant.MonthToken => update.Inc(value => value.MonthTokens, 1),
AccessGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
AccessGrant.ResetToken => update.Inc(value => value.ResetTokens, 1),
_ => update
};
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
private async Task<AccessStatusResponse?> SpendAndExtendAsync(
string userId, string operationId, int credits, string? tokenProperty, TimeSpan duration)
{
if (string.IsNullOrWhiteSpace(operationId)) return null;
var now = _time.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserAccessAccount>.Filter;
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.AnyEq(value => value.AppliedOperationIds, operationId));
if (credits > 0) filter &= filters.Gte(value => value.Credits, credits);
filter &= tokenProperty switch
{
nameof(UserAccessAccount.DayTokens) => filters.Gt(value => value.DayTokens, 0),
nameof(UserAccessAccount.WeekTokens) => filters.Gt(value => value.WeekTokens, 0),
nameof(UserAccessAccount.MonthTokens) => filters.Gt(value => value.MonthTokens, 0),
_ => FilterDefinition<UserAccessAccount>.Empty
};
var account = await _accounts.Find(filter).FirstOrDefaultAsync();
if (account is null) return null;
var start = account.UnlimitedUntilUtc is { } expiry && expiry > now ? expiry : now;
var update = Builders<UserAccessAccount>.Update
.Set(value => value.UnlimitedUntilUtc, start.Add(duration))
.AddToSet(value => value.AppliedOperationIds, operationId)
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.Revision, 1);
if (credits > 0) update = update.Inc(value => value.Credits, -credits);
update = tokenProperty switch
{
nameof(UserAccessAccount.DayTokens) => update.Inc(value => value.DayTokens, -1),
nameof(UserAccessAccount.WeekTokens) => update.Inc(value => value.WeekTokens, -1),
nameof(UserAccessAccount.MonthTokens) => update.Inc(value => value.MonthTokens, -1),
_ => update
};
var applied = await _accounts.UpdateOneAsync(
filter & filters.Eq(value => value.Revision, account.Revision), update);
return applied.ModifiedCount == 1 ? await GetStatusAsync(userId) : null;
}
private async Task EnsureAndCleanAsync(string userId, DateTime now)
{
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserAccessAccount>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
var cutoff = now.AddHours(-24);
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserAccessAccount>.Update
.PullFilter(value => value.RecentUsage, item => item.CreatedUtc <= cutoff)
.PullFilter(value => value.RecentCredits, item => item.CreatedUtc <= cutoff));
}
private static AccessStatusResponse ToStatus(UserAccessAccount account, DateTime now, bool bypass)
{
var unlimited = bypass || account.UnlimitedUntilUtc > now;
var used = account.RecentUsage.Count;
return new AccessStatusResponse
{
QuestionsUsed = used,
QuestionsRemaining = unlimited ? AccessCatalog.FreeQuestionLimit : Math.Max(0, AccessCatalog.FreeQuestionLimit - used),
NextQuestionAvailableUtc = !unlimited && used >= AccessCatalog.FreeQuestionLimit
? account.RecentUsage.MinBy(value => value.CreatedUtc)?.CreatedUtc.AddHours(24)
: null,
Credits = account.Credits,
DayTokens = account.DayTokens,
WeekTokens = account.WeekTokens,
MonthTokens = account.MonthTokens,
ExamTokens = account.ExamTokens,
ResetTokens = account.ResetTokens,
BankCycle = account.BankCycle,
Unlimited = unlimited,
UnlimitedUntilUtc = account.UnlimitedUntilUtc,
Products = AccessCatalog.Products.Values.ToList()
};
}
}
@@ -25,11 +25,8 @@ public static class ContentPromotionCommand
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
var questions = await source.GetCollection<BsonDocument>("Questions")
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
var questions = sourceQuestions
.Select(QuestionPromotionCommand.PrepareForProduction)
.ToList();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
-84
View File
@@ -1,84 +0,0 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CreditStore
{
private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly TimeProvider _clock;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_clock = clock;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
}
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
{
if (string.IsNullOrWhiteSpace(attemptId)) return false;
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserCreditAccount>.Filter;
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
{
new BsonDocument("$size", "$RecentEarnings"),
CreditEconomy.RollingEarningLimit
})));
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.ElemMatch(value => value.RecentEarnings,
earning => earning.OperationId == attemptId)) &
belowCap;
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, 1)
.Push(value => value.RecentEarnings, new CreditEvent
{
OperationId = attemptId,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return new CreditStatusResponse
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count,
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
};
}
private async Task EnsureAndCleanAsync(string userId, DateTime now)
{
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.Credits, 0)
.SetOnInsert(value => value.RecentEarnings, [])
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
}
}
+7 -2
View File
@@ -20,6 +20,7 @@ public sealed class DashboardStore
private readonly FlashcardScheduleStore _flashcardSchedules;
private readonly RatingStore _ratingStore;
private readonly CcsRatingStore _ccsRatingStore;
private readonly AccessStore _accessStore;
private readonly IMongoCollection<UserRating> _ratings;
public static readonly string[] Step3Systems =
@@ -48,7 +49,8 @@ public sealed class DashboardStore
FlashcardStudyService flashcards,
FlashcardScheduleStore flashcardSchedules,
RatingStore ratingStore,
CcsRatingStore ccsRatingStore)
CcsRatingStore ccsRatingStore,
AccessStore accessStore)
{
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
@@ -65,6 +67,7 @@ public sealed class DashboardStore
_flashcardSchedules = flashcardSchedules;
_ratingStore = ratingStore;
_ccsRatingStore = ccsRatingStore;
_accessStore = accessStore;
}
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
@@ -72,7 +75,9 @@ public sealed class DashboardStore
var today = nowUtc.Date;
var trendStart = today.AddDays(-29);
var weekStart = today.AddDays(-6);
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
var bankCycleTask = _accessStore.GetBankCycleAsync(userId);
var bankCycle = await bankCycleTask;
var attemptsTask = _attempts.Find(value => value.UserId == userId && value.BankCycle == bankCycle).ToListAsync();
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
@@ -15,12 +15,20 @@ public class QuestionAttemptStore
{
_col = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
try
{
_col.Indexes.DropOne("QuestionExternalId_1_UserId_1");
}
catch (MongoCommandException exception) when (exception.Code is 26 or 27)
{
}
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
.Ascending(a => a.QuestionExternalId)
.Ascending(a => a.UserId);
.Ascending(a => a.UserId)
.Ascending(a => a.BankCycle);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
firstAttemptIndex,
new CreateIndexOptions { Unique = true }));
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
@@ -38,12 +46,16 @@ public class QuestionAttemptStore
bool isCorrect,
bool isSkip = false,
IReadOnlyCollection<string>? presentedChoiceIds = null,
int durationSeconds = 0)
int durationSeconds = 0,
int bankCycle = 0,
string completionId = "")
{
var attempt = new QuestionAttempt
{
QuestionExternalId = questionExternalId,
UserId = userId,
BankCycle = bankCycle,
CompletionId = completionId,
ChoiceId = choiceId,
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
TracksPresentation = presentedChoiceIds is { Count: > 0 },
@@ -63,7 +75,7 @@ public class QuestionAttemptStore
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var existing = await _col
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId && a.BankCycle == bankCycle)
.FirstAsync();
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
}
@@ -75,10 +87,13 @@ public class QuestionAttemptStore
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
.ToListAsync();
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null) =>
_col.Distinct(
attempt => attempt.QuestionExternalId,
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId) &
(bankCycle is null
? FilterDefinition<QuestionAttempt>.Empty
: Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value)))
.ToListAsync();
public IMongoCollection<QuestionAttempt> Collection => _col;
@@ -9,8 +9,6 @@ namespace QuestionsApi.Services;
public static class QuestionPromotionCommand
{
public const string Confirmation = "PROMOTE_TO_PRD";
internal const string ProductionMediaReviewReason = "Contains media - pending production review";
internal const string ProductionMediaReviewActor = "system:production-promotion";
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
@@ -30,9 +28,9 @@ public static class QuestionPromotionCommand
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
var questions = await source.GetCollection<BsonDocument>("Questions")
.Find(questionFilter).ToListAsync();
var duplicateIds = sourceQuestions
var duplicateIds = questions
.GroupBy(ExternalId, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
@@ -40,7 +38,6 @@ public static class QuestionPromotionCommand
if (duplicateIds.Count > 0)
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
var questions = sourceQuestions.Select(PrepareForProduction).ToList();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
@@ -98,7 +95,7 @@ public static class QuestionPromotionCommand
["SourceDatabase"] = options.SourceDatabase,
["TargetDatabase"] = options.TargetDatabase,
["Scope"] = "questions",
["Eligibility"] = "published_unflagged; media_requires_production_review",
["Eligibility"] = "published_unflagged",
["Questions"] = summary.EligibleQuestions,
["Inserted"] = summary.Inserted,
["Updated"] = summary.Updated,
@@ -121,21 +118,6 @@ public static class QuestionPromotionCommand
question.GetValue("IsPublished", false).ToBoolean()
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
internal static BsonDocument PrepareForProduction(BsonDocument source)
{
var prepared = new BsonDocument(source);
if (!HasMedia(prepared))
return prepared;
var reviewUtc = prepared.TryGetValue("UpdatedUtc", out var updated) && updated.IsValidDateTime
? updated
: new BsonDateTime(DateTime.UnixEpoch);
prepared["FlaggedForReviewUtc"] = reviewUtc;
prepared["FlagReason"] = ProductionMediaReviewReason;
prepared["FlaggedByUserId"] = ProductionMediaReviewActor;
return prepared;
}
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
{
var ids = new HashSet<ObjectId>();
@@ -210,11 +192,6 @@ public static class QuestionPromotionCommand
private static string ExternalId(BsonDocument document) =>
document.GetValue("ExternalId", "").AsString;
private static bool HasMedia(BsonDocument question) =>
question.TryGetValue("Media", out var media)
&& media.IsBsonArray
&& media.AsBsonArray.Count > 0;
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
{
var collection = target.GetCollection<BsonDocument>("Questions");
@@ -247,7 +224,7 @@ public static class QuestionPromotionCommand
{
mode,
scope = "questions",
eligibility = "published_unflagged; media_requires_production_review",
eligibility = "published_unflagged",
source = options.SourceDatabase,
target = options.TargetDatabase,
eligibleQuestions = summary.EligibleQuestions,
@@ -68,31 +68,11 @@ public static class QuestionValidator
if (string.IsNullOrWhiteSpace(req.System)) return "system required";
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5";
var labValidation = ValidateLabs(req.Labs, req.IsPublished);
if (labValidation is not null) return labValidation;
var referenceValidation = ValidateReferences(req.References, req.IsPublished);
if (referenceValidation is not null) return referenceValidation;
return null;
}
public static string? ValidateLabs(IReadOnlyCollection<QuestionLab>? labs, bool requireComplete)
{
if (labs is null)
return "labs must be an array";
if (labs.Count > 50)
return "questions support at most fifty lab results";
foreach (var lab in labs)
{
if (string.IsNullOrWhiteSpace(lab.Name) || string.IsNullOrWhiteSpace(lab.Value))
return "lab name and value are required";
if (requireComplete && string.IsNullOrWhiteSpace(lab.Reference))
return $"published question lab '{lab.Name.Trim()}' requires a reference range";
}
return null;
}
public static string? ValidateReferences(IReadOnlyCollection<QuestionReference>? references, bool required)
{
if (references is null)
+13 -30
View File
@@ -27,49 +27,32 @@ public class RatingStore
public async Task<RatingProfileResponse> GetProfileAsync(string userId)
{
var rating = await GetOrCreateUserRatingAsync(userId);
var (tierName, tierDivision) = GetTier(rating.OverallElo);
return new RatingProfileResponse
{
OverallElo = rating.OverallElo,
RatedQuestionCount = rating.RatedQuestionCount,
CategoryElos = rating.CategoryElos,
Tier = tierName,
TierDivision = tierDivision
CategoryElos = rating.CategoryElos
};
}
private static readonly (string Name, int FloorElo)[] TierBands =
[
("Bronze", MinElo),
("Silver", 1000),
("Gold", 1300),
("Platinum", 1900),
("Diamond", 2200)
];
private static (string Name, int Division) GetTier(int elo)
{
var bandIndex = 0;
for (var i = 0; i < TierBands.Length; i++)
{
if (elo >= TierBands[i].FloorElo)
bandIndex = i;
}
var band = TierBands[bandIndex];
var nextFloor = bandIndex + 1 < TierBands.Length ? TierBands[bandIndex + 1].FloorElo : band.FloorElo + 300;
var bandWidth = nextFloor - band.FloorElo;
var progress = bandWidth > 0 ? (elo - band.FloorElo) / (double)bandWidth : 0;
var division = 3 - Math.Clamp((int)(progress * 3), 0, 2);
return (band.Name, division);
}
public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{
var rating = await GetOrCreateUserRatingAsync(userId);
return await EnsureHistoryBaselineAsync(rating, nowUtc);
}
public Task ResetUserAsync(string userId, DateTime nowUtc) =>
_users.UpdateOneAsync(
rating => rating.UserId == userId,
Builders<UserRating>.Update
.Set(rating => rating.OverallElo, BaseElo)
.Set(rating => rating.RatedQuestionCount, 0)
.Set(rating => rating.CategoryElos, DefaultCategoryElos())
.Set(rating => rating.HistoryBaselineElo, null)
.Set(rating => rating.HistoryStartedUtc, null)
.Set(rating => rating.UpdatedUtc, nowUtc),
new UpdateOptions { IsUpsert = false });
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
{
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
@@ -0,0 +1,63 @@
using QuestionsApi.Models;
using Stripe;
using Stripe.Checkout;
namespace QuestionsApi.Services;
public interface IStripeAccessGateway
{
Task<StripeCheckoutResult> CreateCheckoutAsync(AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken);
}
public sealed class StripeAccessGateway(IConfiguration configuration) : IStripeAccessGateway
{
public async Task<StripeCheckoutResult> CreateCheckoutAsync(
AccessPurchase purchase, AccessProduct product, CancellationToken cancellationToken)
{
var secretKey = configuration["Stripe:SecretKey"];
if (string.IsNullOrWhiteSpace(secretKey)) throw new InvalidOperationException("Stripe secret key is not configured.");
var successUrl = configuration["Stripe:AccessSuccessUrl"]
?? "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}";
var cancelUrl = configuration["Stripe:AccessCancelUrl"]
?? "https://doctor.ranaze.com/donation-canceled.html";
var metadata = new Dictionary<string, string>
{
["access_purchase_id"] = purchase.Id ?? "",
["access_sku"] = product.Sku,
["user_id"] = purchase.UserId
};
var service = new SessionService(new StripeClient(secretKey));
var session = await service.CreateAsync(new SessionCreateOptions
{
Mode = "payment",
SuccessUrl = successUrl,
CancelUrl = cancelUrl,
ClientReferenceId = purchase.Id,
CustomerCreation = "always",
BillingAddressCollection = "auto",
AutomaticTax = new SessionAutomaticTaxOptions { Enabled = true },
Metadata = metadata,
PaymentIntentData = new SessionPaymentIntentDataOptions { Metadata = metadata },
LineItems =
[
new SessionLineItemOptions
{
Quantity = 1,
PriceData = new SessionLineItemPriceDataOptions
{
Currency = product.Currency,
UnitAmount = product.AmountCents,
ProductData = new SessionLineItemPriceDataProductDataOptions
{
Name = product.Name,
Description = "A manually activated RanaZe access entitlement."
}
}
}
]
}, null, cancellationToken);
if (string.IsNullOrWhiteSpace(session.Id) || string.IsNullOrWhiteSpace(session.Url))
throw new InvalidOperationException("Stripe did not return a checkout URL.");
return new StripeCheckoutResult(session.Id, session.Url);
}
}
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
"ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserCreditAccounts"
"UserAccessAccounts", "AccessPurchases"
];
public UserProfileStore(MongoDatabaseProvider mongo)
@@ -4,7 +4,9 @@
"AnswerStatistics": { "ReconcileSeconds": 300 },
"Stripe": {
"SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
"CancelUrl": "https://doctor.ranaze.com/donation-canceled.html",
"AccessSuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}",
"AccessCancelUrl": "https://doctor.ranaze.com/donation-canceled.html"
},
"Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" },
"PasswordReset": {
+3 -1
View File
@@ -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",
-13
View File
@@ -1,13 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.79 17 5 19 5a1 1 0 0 1 1 1z" />
</svg>

Before

Width:  |  Height:  |  Size: 381 B

-43
View File
@@ -1,43 +0,0 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b6wxo6lr3s8ll"
path="res://.godot/imported/shield.svg-fbfc812301b797aa0266f7b4e83e6ef3.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/shield.svg"
dest_files=["res://.godot/imported/shield.svg-fbfc812301b797aa0266f7b4e83e6ef3.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+82
View File
@@ -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.
-19
View File
@@ -1,19 +0,0 @@
# Question-completion credits
This branch introduces credit earning only. It does not impose a question
limit, redeem credits, sell products, activate passes, consume exam tokens, or
reset QBank progress.
- A successfully stored first answer earns one credit regardless of whether it
is correct.
- A repeated answer does not earn another credit.
- A skipped question does not earn credit.
- At most 20 credits can be earned in a rolling 24-hour window.
- Historical attempts are not backfilled.
- `GET /api/credits` returns the authenticated user's balance and current
rolling-window capacity.
- The first-attempt response also includes the updated credit status.
The API is authoritative. Credit grants use the immutable attempt ID as their
idempotency key, so retrying the same completed question cannot grant another
credit. Credit accounts are included in account export and deletion.
+275
View File
@@ -0,0 +1,275 @@
# Steam alpha release checklist
Use this as the go/no-go record for the first external Windows alpha of
**RanaZe: Step 3 Trainer**. Put the owner, completion date, and a link to the
evidence beside each checked item. An unchecked **BLOCKER** means do not admit
testers.
## Release decision
- [x] **BLOCKER — Choose the release vehicle.** **Decision: release the app
free on Steam and keep the existing public web client available. Both clients
use the same account access model. QBank passes and study tokens are optional
in-app purchases; Steam purchases remain disabled until Steamworks
Microtransactions are configured.**
- [ ] Record the decision, target date, tester count, supported countries,
tester-selection method, feedback channel, and person with final go/no-go
authority.
- [ ] Define alpha success and stop conditions: minimum completed sessions,
acceptable crash/error rate, critical defect threshold, support response
time, server-capacity ceiling, and the date results will be reviewed.
- [ ] Define the advertised alpha scope. List working features, known
limitations, expected resets or breaking changes, and features explicitly
excluded from this test.
Decision: Free Steam alpha plus the existing public web client; shared freemium access model
Target date/time and time zone: ____________________
Release owner: ____________________
Build commit SHA: ____________________
Steam base AppID: ____________________
Steam Playtest AppID (if used): ____________________
## Steamworks account and application
- [ ] **BLOCKER — Complete Steamworks onboarding.** Verify the legal business
name, bank and tax details, identity verification, Steam Distribution
Agreement, and team permissions.
- [ ] Pay and activate the Steam Direct app credit for the base application
(currently USD 100 per product, subject to tax).
- [ ] Create the base application and save its AppID above.
- [ ] For a Playtest, create the child Playtest app from **Associated Packages
& DLC**, choose its public name carefully, and save its AppID above. The
public Playtest name cannot be changed after release.
- [ ] Restrict Steamworks permissions to the people who need them; require
Steam Guard and make sure at least two trusted people can handle a release.
- [ ] Decide whether the main store page will be public. A Playtest can instead
use keys without making signups or the store page public, but testers should
not be promised confidentiality merely because access is limited.
## Content, legal, and privacy clearance
- [ ] **BLOCKER — Complete the repository's outstanding originality review for
every released question and explanation.** Retain reviewer, date, sources,
revisions, and disposition.
- [ ] **BLOCKER — Complete the outstanding copyright/license audit for every
image-bearing question.** Remove or replace anything whose reuse rights
cannot be established.
- [ ] **BLOCKER — Complete the duplicate-question review and authoritative
reference verification described in `README.md`.** Release only published,
unflagged, cleared content.
- [ ] Review all other shipped assets—branding, fonts, icons, audio, code,
screenshots, trailer footage, and third-party notices—and retain a license
inventory.
- [ ] Have qualified counsel/ownership review the Terms of Service, Privacy
Policy, educational/medical disclaimer, trademark claims, refund/customer
terms, and the handling of study notes or other user-entered data.
- [ ] Confirm the Steam store description and screenshots never imply medical
advice, clinical certification, guaranteed exam performance, or affiliation
with USMLE, NBME, FSMB, or a licensing authority.
- [ ] Complete Steam's Content Survey honestly and make its answers consistent
with both the build and store page.
- [ ] Verify `[email protected]` is monitored and that account data export,
permanent deletion, provider unlinking, and privacy requests work end to end.
- [ ] Publish and test the production legal pages:
`https://step3.ranaze.com/terms.html` and
`https://step3.ranaze.com/privacy.html`.
## Production service readiness
- [ ] **BLOCKER — Complete the initial PRD launch and question promotion in
`ENVIRONMENTS.md`, including dry run, approval, apply, and post-promotion
verification.**
- [ ] Confirm PRD uses a unique JWT key, PRD Mongo database, production host
names, production legal versions, and production secrets; no UAT data or
credentials may be present.
- [ ] Configure and test the live Steam callbacks, including:
`https://s3q.ranaze.com/api/Auth/steam/callback` and
`https://s3q.ranaze.com/api/Auth/external/steam/callback`.
- [ ] From a clean Steam account, test create-account, link-existing-account,
sign-out/sign-in, expired/cancelled authentication, unlinking, and account
deletion. Confirm the app never asks for or receives a Steam password.
- [ ] **BLOCKER — Configure and test Stripe access purchases on the web** at
both `https://us3q.ranaze.com/api/access/stripe-webhook` in test mode and
`https://s3q.ranaze.com/api/access/stripe-webhook` in live mode. Use distinct
signing secrets, subscribe the required Checkout events, and test all five
fixed-price products. Keep the separate contribution flow only if wanted.
- [ ] **BLOCKER — Keep Stripe checkout unavailable in the Steam build.** Before
enabling purchases there, configure Steamworks Microtransactions, product
IDs, callbacks, reconciliation, refunds, and sandbox tests. Confirm the
desktop export carries the `steam` feature flag.
- [ ] Exercise production readiness endpoints and dashboards, and configure
alerts for API unavailability, elevated 5xx responses, authentication
failures, database capacity, and certificate expiry.
- [ ] Load-test the API at the planned tester count plus headroom. Document a
tester-admission cap and who can stop new admissions.
- [ ] Take and restore-test a production database backup. Record retention,
rollback, incident-response, and credential-rotation procedures.
## Windows release build
- [ ] Install the required Godot 4.6.x release export templates and confirm the
pinned engine version used for the release.
- [ ] Update all visible versions from `pre-alpha`/`0.1.0` to the approved alpha
version in `project.godot`, `export_presets.cfg`, the UI, and release notes.
- [ ] Decide whether to disable the Windows console wrapper
(`debug/export_console_wrapper=1`) for tester-facing builds.
- [ ] Decide whether to Authenticode-sign `RanaZe.exe`; if signing, protect the
certificate, timestamp the executable, and verify the signature.
- [ ] Export only from the reviewed release commit, with a clean working tree,
using Git Bash:
```bash
GODOT_BIN=/absolute/path/to/Godot_v4.6.x-stable_win64_console.exe \
./tools/export_steam_release.sh build/windows/RanaZe.exe
```
- [ ] **BLOCKER — Verify the export reports `deployment_environment="prd"`,
the exact release commit in `build_revision`, and only `s3q.ranaze.com`
production API endpoints.**
- [ ] Confirm the upload directory contains `RanaZe.exe`, `RanaZe.pck`, and all
runtime dependencies, but no source, secrets, `.env` files, export backup,
debug symbols not intended for testers, or test data.
- [ ] Virus-scan the staged files and archive a manifest containing SHA-256
hashes, build command, Godot version, commit SHA, build time, and builder.
## SteamPipe and Steam configuration
- [ ] Download the current Steamworks SDK. The SDK is required to upload with
SteamPipe; integrating the Steamworks runtime API is optional for this app's
existing browser-based Steam OpenID flow.
- [ ] Create the Windows depot and default package for the selected AppID.
- [ ] Create version-controlled SteamPipe app/depot VDF templates with AppIDs
but no Steam credentials. Point the depot content root at the staged Windows
directory.
- [ ] Upload through `steamcmd`, add a human-readable build description with
the alpha version and commit SHA, and first assign it to a password-protected
`alpha` branch.
- [ ] Configure the Windows launch option to run `RanaZe.exe` from the depot
root. Verify OS is Windows and architecture is 64-bit.
- [ ] Set supported language(s) to match what is actually available in the
build. Do not claim interface, audio, or subtitle support that has not been
tested.
- [ ] Configure install/uninstall behavior and required redistributables, if
any. Confirm uninstall does not remove cloud/server account data and that
locally generated sensitive files are understood.
- [ ] If using a Playtest, upload and configure the build on the **Playtest
AppID**, not merely the base game's depot.
- [ ] Test installation, launch, update, rollback, file verification, uninstall,
and reinstall entirely through the Steam client—never only by running the
local export.
## Store and tester communication
- [ ] Prepare truthful short and long descriptions, developer/publisher names,
tags, platform/language support, system requirements, support contact, and
privacy-policy URL.
- [ ] Create required store, library, community, and client graphical assets at
Steam's current dimensions. Use actual alpha screenshots and clearly label
footage or features that are not representative of the current build.
- [ ] Check all asset rights and Steam's graphical asset rules. Localize text
appearing in graphical assets for every language the game claims to support.
- [ ] For Early Access only, complete the Early Access questionnaire, describe
the current state and update plan without promises the team cannot guarantee,
set a price justified by the current build, and use required Early Access
branding wherever keys are sold.
- [ ] Draft tester instructions covering scope, known issues, medical
disclaimer, feedback/bug-report format, log/screenshot handling, privacy,
support hours, update/reset expectations, and test end date.
- [ ] Draft the launch announcement, first patch notes, outage message, and
alpha-ending message before admitting testers.
## Release-candidate acceptance
- [ ] Promote the exact uploaded build to the protected `alpha` branch and have
at least two people test it independently on clean Windows 10 and Windows 11
user profiles.
- [ ] Test at 1920×1080 plus a smaller supported resolution and at 100%, 125%,
and 150% Windows scaling. Check keyboard/mouse navigation, text clipping,
external browser transitions, focus return, and readable error states.
- [ ] Run the complete learning journey: registration and legal consent,
practice question, randomized choices, answer/explanation/reference,
dashboard, review queue, notes/bookmarks, flashcards, CCS, feedback/report,
data export, and account deletion.
- [ ] Run the access journey on web: questions 1–20, rolling-window exhaustion,
one credit per eligible first attempt, no credit for skips/repeats, the
20-credit earning cap, 100-credit redemption, purchase and manual activation
of each pass, exam-token consumption, reset confirmation, and retained
bookmarks/notes after reset.
- [ ] Test offline start, connection loss during a session, API timeout, expired
token, server maintenance, malformed response, and recovery without corrupting
learner progress.
- [ ] Run automated API/Godot tests and the production-equivalent smoke suite;
attach results to the release record. Do not rely only on the web UAT smoke
suite for the Windows Steam build.
- [ ] Verify every feature and claim shown on the store page exists in the
submitted build.
- [ ] Triage all findings. Zero unresolved critical issues; every accepted
non-critical issue has an owner, rationale, workaround, and target release.
- [ ] Obtain written sign-off from engineering, content/medical review, legal/
privacy, operations, and the release owner.
## Valve review and scheduling
- [ ] Complete the selected app's Steamworks store-presence checklist and submit
it for review. Valve advises allowing at least seven business days even though
reviews commonly take three to five business days.
- [ ] After store presence is submitted, complete the game-build checklist,
place a mostly final build on the default branch for Valve, and submit the
build for review.
- [ ] Resolve every Valve review note and repeat the relevant acceptance tests
against any changed build or store material.
- [ ] If publishing the main Coming Soon page, allow at least the current
Steam-required visibility period before an Early Access/full release. Confirm
the exact eligible release date shown in Steamworks rather than calculating
it from memory.
- [ ] For a Playtest, choose limited or open signup, batch size/countries, or
key distribution. Never charge for Playtest access or sell Playtest keys.
- [ ] Schedule the release while the release owner, backend operator, and
support contact are available for the initial monitoring window.
## Launch day
- [ ] Freeze unrelated PRD and client changes; confirm the approved commit,
Steam build ID, depot manifest, store copy, tester count, and rollback build.
- [ ] Re-run public health/readiness checks, login, one question flow, feedback,
legal links, database backup status, alerts, and Steam callback.
- [ ] Set the approved build live on the intended Steam branch.
- [ ] For a Playtest, set the app **Playable**, then admit only the planned
first batch or enable the approved signup mode.
- [ ] Install and launch from a tester account with no developer permissions.
Verify download size, executable, version/SHA, production environment, and
end-to-end authentication.
- [ ] Publish tester instructions/announcement and open the staffed feedback
channel.
- [ ] Record release time, Steam build ID, admitted tester count, incidents,
decisions, and changes in the release log.
## Monitoring, rollback, and alpha close
- [ ] Monitor API/error/auth rates, capacity, reports, support inbox, Steam
discussions, and content-safety feedback at the defined cadence.
- [ ] Acknowledge critical medical-accuracy or copyright reports immediately;
unpublish affected content while it is reviewed.
- [ ] Stop admissions for capacity or support overload. Set the Playtest to
**Not Playable** or roll back its live branch when a stop condition is met,
and communicate the impact to testers.
- [ ] Patch from a reviewed commit, repeat affected acceptance tests, upload to
the protected branch, test through Steam, then promote the exact build.
- [ ] At alpha end, announce the deadline, stop signups, communicate account/
progress retention, and set the Playtest to **Not Playable** when appropriate.
- [ ] Export metrics without exposing personal data; triage feedback into owned
work; hold a retrospective; document the next test's entry criteria.
- [ ] Retain build manifests, approvals, test evidence, incidents, content
review records, and the final release log according to the team's policy.
## Official Steam references
- [Steam Playtest](https://partner.steamgames.com/doc/features/playtest)
- [Steam release process](https://partner.steamgames.com/doc/store/releasing)
- [Steam review process](https://partner.steamgames.com/doc/store/review_process)
- [Uploading to Steam / SteamPipe](https://partner.steamgames.com/doc/sdk/uploading)
- [Steam Direct fee](https://partner.steamgames.com/doc/gettingstarted/appfee)
- [Steamworks onboarding](https://partner.steamgames.com/doc/gettingstarted/onboarding)
- [Content Survey](https://partner.steamgames.com/doc/gettingstarted/contentsurvey)
- [Early Access](https://partner.steamgames.com/doc/store/earlyaccess)
- [Store graphical assets](https://partner.steamgames.com/doc/store/assets/standard)
+11 -8
View File
@@ -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.
+2 -2
View File
@@ -25,7 +25,7 @@ html/custom_html_shell="res://web/medmind_shell.html"
html/head_include="<meta name=\"description\" content=\"RanaZe: Step 3 Trainer pre-alpha - Focused practice for better clinical decisions.\">"
html/canvas_resize_policy=2
html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=true
html/experimental_virtual_keyboard=false
progressive_web_app/enabled=false
[preset.1]
@@ -35,7 +35,7 @@ platform="Windows Desktop"
runnable=true
advanced_options=false
dedicated_server=false
custom_features=""
custom_features="steam"
export_filter="all_resources"
include_filter=""
exclude_filter=""
+5
View File
@@ -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:
+2
View File
@@ -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
+2
View File
@@ -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
+7 -5
View File
@@ -13,13 +13,14 @@ config_version=5
config/name="RanaZe: Step 3 Trainer"
config/version="pre-alpha"
run/main_scene="res://scenes/main.tscn"
config/features=PackedStringArray("4.6", "GL Compatibility")
config/icon="res://assets/branding/ranaze-app-icon.png"
config/use_custom_user_dir=true
config/custom_user_dir_name="MedMind"
config/features=PackedStringArray("4.6", "GL Compatibility")
boot_splash/bg_color=Color(0.019607844, 0.02745098, 0.05882353, 1)
boot_splash/stretch_mode=0
boot_splash/image="res://assets/branding/ranaze-mark.png"
config/icon="res://assets/branding/ranaze-app-icon.png"
boot_splash/fullsize=false
boot_splash/use_filter=true
[autoload]
@@ -39,10 +40,10 @@ NotesService="*res://scripts/autoload/notes_service.gd"
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
DashboardService="*res://scripts/autoload/dashboard_service.gd"
CreditsService="*res://scripts/autoload/credits_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]
@@ -57,9 +58,9 @@ window/stretch/aspect="expand"
[rendering]
rendering_device/driver.windows="d3d12"
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"
rendering_device/driver.windows="d3d12"
textures/vram_compression/import_etc2_astc=true
[step3]
@@ -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"
+249
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://ch2scupdmgjpj
-67
View File
@@ -1,67 +0,0 @@
extends Node
signal credits_changed
signal credits_failed(message: String)
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
var credits: int = 0
var loaded := false
var loading := false
var request: HTTPRequest
var request_token := ""
var api_url := DEFAULT_URL
func _ready() -> void:
api_url = EndpointConfig.resolve(
str(ProjectSettings.get_setting("step3/credits_api_url", DEFAULT_URL)),
"/api/credits")
request = HTTPRequest.new()
request.timeout = 20.0
request.request_completed.connect(_on_completed)
add_child(request)
AuthState.auth_changed.connect(_on_auth_changed)
func refresh() -> void:
if loading or not AuthState.can_send_authenticated_request():
return
loading = true
request_token = AuthState.access_token
var headers := PackedStringArray(["Authorization: Bearer %s" % request_token])
var error := request.request(api_url, headers, HTTPClient.METHOD_GET)
if error != OK:
loading = false
credits_failed.emit("Points balance could not be loaded. Check your connection and try again.")
func apply_status(parsed: Dictionary) -> void:
credits = int(parsed.get("credits", credits))
loaded = true
credits_changed.emit()
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
loading = false
if response_code == 401:
AuthState.handle_unauthorized(request_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
credits_failed.emit("Points balance could not be loaded. Check your connection and try again.")
return
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
if typeof(parsed) != TYPE_DICTIONARY:
credits_failed.emit("The credits service returned an invalid response.")
return
apply_status(parsed)
func _on_auth_changed() -> void:
if not AuthState.is_logged_in:
credits = 0
loaded = false
loading = false
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
request.cancel_request()
-1
View File
@@ -1 +0,0 @@
uid://bi323du7o7dn2
+37 -17
View File
@@ -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
@@ -38,8 +39,6 @@ var load_state := "idle"
var retry_attempt := 0
var next_retry_seconds := 0
var overall_elo := 1000
var rank_tier := "Bronze"
var rank_division := 3
var rated_question_count := 0
var category_elos := {}
var last_rating_change := {}
@@ -159,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
@@ -248,8 +255,6 @@ func clear_questions() -> void:
retry_attempt = 0
next_retry_seconds = 0
overall_elo = 1000
rank_tier = "Bronze"
rank_division = 3
rated_question_count = 0
category_elos = {}
last_rating_change = {}
@@ -261,10 +266,10 @@ func clear_questions() -> void:
ratings_changed.emit()
func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void:
func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void:
if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "":
return
_enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds, met_time_limit)
_enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds)
func skip_question(question_id: String) -> void:
@@ -390,7 +395,7 @@ func _on_retry_timer_timeout() -> void:
load_questions_from_api(false)
func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0, met_time_limit: bool = true) -> void:
func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void:
var account := AuthState.username.strip_edges()
if account == "":
return
@@ -404,7 +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),
"met_time_limit": met_time_limit,
"completion_id": _new_completion_id(question_id),
"retry_count": 0
})
_save_attempt_queue()
@@ -426,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",
@@ -434,14 +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)),
"metTimeLimit": bool(queued.get("met_time_limit", true))
"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
@@ -480,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()
@@ -498,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", "")))
@@ -513,9 +538,6 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
var rating: Dictionary = parsed.get("rating", {})
_update_question_elo(question_id, question_elo)
_apply_rating_change(rating)
var credits_status: Variant = parsed.get("credits", null)
if typeof(credits_status) == TYPE_DICTIONARY:
CreditsService.apply_status(credits_status)
choice_percentages_updated.emit(question_id, percentages)
question_rating_updated.emit(question_id, question_elo, rating)
@@ -621,8 +643,6 @@ func _on_ratings_loaded(result: int, response_code: int, _headers: PackedStringA
return
overall_elo = int(parsed.get("overallElo", parsed.get("overall_elo", 1000)))
rank_tier = str(parsed.get("tier", rank_tier))
rank_division = int(parsed.get("tierDivision", parsed.get("tier_division", rank_division)))
rated_question_count = int(parsed.get("ratedQuestionCount", parsed.get("rated_question_count", 0)))
category_elos = parsed.get("categoryElos", parsed.get("category_elos", {}))
ratings_changed.emit()
+190
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://bgkgwx6ds6cjn
+2 -2
View File
@@ -101,8 +101,8 @@ func refresh_theme_controls() -> void:
return
var dark := UIThemeService.is_dark_mode()
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
display_label.add_theme_color_override("font_color", Color.WHITE)
operation_label.add_theme_color_override("font_color", Brand.muted_color(true))
display_label.add_theme_color_override("font_color", Brand.text_color(dark))
operation_label.add_theme_color_override("font_color", Brand.muted_color(dark))
for button in keypad_buttons:
var accent := Brand.ROYAL_BLUE if str(button.get_meta("calculator_token", "")) in ["+", "−", "×", "÷", "="] else Brand.SLATE
button.add_theme_stylebox_override("normal", Brand.badge_box(dark, accent))
-165
View File
@@ -1,165 +0,0 @@
extends PanelContainer
const Brand := preload("res://scripts/ui/medmind_style.gd")
const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg")
const STAR_ICON := preload("res://assets/icons/lucide/star.svg")
const CHEVRON_ICON := preload("res://assets/icons/lucide/chevron-down.svg")
var rank_icon_panel: PanelContainer
var rank_icon_texture: TextureRect
var rank_tier_label: Label
var rank_elo_label: Label
var divider: ColorRect
var icon_panel: PanelContainer
var icon_texture: TextureRect
var value_label: Label
var caption_label: Label
var displayed_value := 0
var _initialized := false
func _ready() -> void:
size_flags_vertical = Control.SIZE_SHRINK_CENTER
_build_ui()
refresh_theme_controls()
_on_credits_changed()
_on_ratings_changed()
CreditsService.credits_changed.connect(_on_credits_changed)
QuestionBankService.ratings_changed.connect(_on_ratings_changed)
resized.connect(func(): pivot_offset = size * 0.5)
func _build_ui() -> void:
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 7)
margin.add_theme_constant_override("margin_right", 7)
margin.add_theme_constant_override("margin_top", 5)
margin.add_theme_constant_override("margin_bottom", 5)
add_child(margin)
var row := HBoxContainer.new()
row.add_theme_constant_override("separation", 5)
margin.add_child(row)
rank_icon_panel = PanelContainer.new()
rank_icon_panel.custom_minimum_size = Vector2(22, 22)
rank_icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
rank_icon_texture = TextureRect.new()
rank_icon_texture.texture = SHIELD_ICON
rank_icon_texture.custom_minimum_size = Vector2(13, 13)
rank_icon_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
rank_icon_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
rank_icon_panel.add_child(rank_icon_texture)
row.add_child(rank_icon_panel)
var rank_copy := VBoxContainer.new()
rank_copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
rank_copy.add_theme_constant_override("separation", 0)
rank_tier_label = Label.new()
rank_tier_label.add_theme_font_override("font", Brand.font)
rank_tier_label.add_theme_font_size_override("font_size", 13)
rank_copy.add_child(rank_tier_label)
rank_elo_label = Label.new()
rank_elo_label.add_theme_font_override("font", Brand.font)
rank_elo_label.add_theme_font_size_override("font_size", 9)
rank_copy.add_child(rank_elo_label)
row.add_child(rank_copy)
divider = ColorRect.new()
divider.custom_minimum_size = Vector2(1, 22)
divider.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(divider)
icon_panel = PanelContainer.new()
icon_panel.custom_minimum_size = Vector2(22, 22)
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
icon_texture = TextureRect.new()
icon_texture.texture = STAR_ICON
icon_texture.custom_minimum_size = Vector2(13, 13)
icon_texture.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
icon_texture.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
icon_panel.add_child(icon_texture)
row.add_child(icon_panel)
var copy := VBoxContainer.new()
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
copy.add_theme_constant_override("separation", 0)
value_label = Label.new()
value_label.add_theme_font_override("font", Brand.font)
value_label.add_theme_font_size_override("font_size", 13)
copy.add_child(value_label)
caption_label = Label.new()
caption_label.text = "Rz Coins"
caption_label.add_theme_font_override("font", Brand.font)
caption_label.add_theme_font_size_override("font_size", 9)
copy.add_child(caption_label)
row.add_child(copy)
var chevron := TextureRect.new()
chevron.texture = CHEVRON_ICON
chevron.custom_minimum_size = Vector2(12, 12)
chevron.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
chevron.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
chevron.mouse_filter = Control.MOUSE_FILTER_IGNORE
chevron.size_flags_vertical = Control.SIZE_SHRINK_CENTER
row.add_child(chevron)
func refresh_theme_controls() -> void:
var dark := UIThemeService.is_dark_mode()
add_theme_stylebox_override("panel", Brand.content_card_box(dark, true, Brand.ROYAL_BLUE))
rank_icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.ROYAL_BLUE, 11))
rank_icon_texture.modulate = Brand.ROYAL_BLUE
rank_tier_label.add_theme_color_override("font_color", Brand.text_color(dark))
rank_elo_label.add_theme_color_override("font_color", Brand.ROYAL_BLUE)
divider.color = Color("2a365a") if dark else Brand.LIGHT_BORDER
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(dark, Brand.AMBER, 11))
icon_texture.modulate = Brand.AMBER
value_label.add_theme_color_override("font_color", Brand.text_color(dark))
caption_label.add_theme_color_override("font_color", Brand.muted_color(dark))
func bump() -> void:
var tween := create_tween()
tween.tween_property(self, "scale", Vector2(1.12, 1.12), 0.12).set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT)
tween.tween_property(self, "scale", Vector2.ONE, 0.18).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
func _on_ratings_changed() -> void:
var roman := {1: "I", 2: "II", 3: "III"}
var division_label: String = roman.get(QuestionBankService.rank_division, "")
rank_tier_label.text = "%s %s" % [QuestionBankService.rank_tier, division_label]
rank_elo_label.text = "%d ELO" % QuestionBankService.overall_elo
func _on_credits_changed() -> void:
if not CreditsService.loaded:
_initialized = false
displayed_value = 0
value_label.text = "--"
return
if not _initialized:
_initialized = true
displayed_value = CreditsService.credits
value_label.text = _format_thousands(displayed_value)
return
var tween := create_tween()
tween.tween_method(_set_displayed_value, displayed_value, CreditsService.credits, 0.5).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
func _set_displayed_value(value: int) -> void:
displayed_value = value
value_label.text = _format_thousands(value)
func _format_thousands(value: int) -> String:
var digits := str(absi(value))
var grouped := ""
var count := 0
for index in range(digits.length() - 1, -1, -1):
grouped = digits[index] + grouped
count += 1
if count % 3 == 0 and index != 0:
grouped = "," + grouped
return ("-" if value < 0 else "") + grouped
-1
View File
@@ -1 +0,0 @@
uid://doisoxqnk1mf3
+2 -6
View File
@@ -99,13 +99,9 @@ func _draw() -> void:
static func point_value(point: Dictionary, series_mode: String) -> Variant:
if series_mode == "accuracy":
if int(point.get("total", 0)) > 0:
return float(point.get("accuracy", 0))
return null
return float(point.get("accuracy", 0)) if int(point.get("total", 0)) > 0 else null
var value: Variant = point.get("questionBankRating", null)
if value == null:
return null
return float(value)
return null if value == null else float(value)
static func axis_bounds(series_points: Array, series_mode: String) -> Vector2:
+4 -4
View File
@@ -229,8 +229,8 @@ func _render_rating_card(key: String, sparkline: Control, accent: Color, rating_
if delta_value != null:
var delta := int(delta_value)
var arrow := "↗" if delta >= 0 else "↘"
var sign_prefix := "+" if delta >= 0 else ""
trend_text = "%s %s%d this week" % [arrow, sign_prefix, delta]
var sign := "+" if delta >= 0 else ""
trend_text = "%s %s%d this week" % [arrow, sign, delta]
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
if percentile_value != null:
caption = "Top %d%% of users" % int(percentile_value)
@@ -269,8 +269,8 @@ func _render_accuracy_card(data: Dictionary) -> void:
if delta_value != null:
var delta := int(delta_value)
var arrow := "↗" if delta >= 0 else "↘"
var sign_prefix := "+" if delta >= 0 else ""
trend_text = "%s %s%d%% vs last 100" % [arrow, sign_prefix, delta]
var sign := "+" if delta >= 0 else ""
trend_text = "%s %s%d%% vs last 100" % [arrow, sign, delta]
trend_color = Brand.SUCCESS if delta >= 0 else Brand.MAGENTA
_set_stat_card("accuracy", "%d%%" % accuracy, trend_text, trend_color, "Last %d questions" % int(data.get("accuracySampleSize", 0)))
accuracy_progress_bar.value = accuracy
+1 -10
View File
@@ -65,13 +65,6 @@ var compact_layout := false
var layout_updating := false
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_FOCUS_OUT:
var focus_owner := get_viewport().gui_get_focus_owner()
if focus_owner != null:
focus_owner.release_focus()
func _ready() -> void:
auth_base_url = EndpointConfig.resolve(AUTH_BASE, "/api/Auth")
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
@@ -158,7 +151,6 @@ func _build_ui() -> void:
large_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
brand_row.add_child(large_icon)
var wordmark := VBoxContainer.new()
wordmark.size_flags_vertical = Control.SIZE_SHRINK_CENTER
wordmark.add_theme_constant_override("separation", 4)
brand_row.add_child(wordmark)
var name_row := HBoxContainer.new()
@@ -858,8 +850,7 @@ func _on_steam_status(result: int, code: int, _headers: PackedStringArray, body:
"requires_account":
poll_timer.stop()
_close_steam_popup()
var verified_email_value: Variant = data.get("email")
external_verified_email = "" if verified_email_value == null else str(verified_email_value)
external_verified_email = str(data.get("email", ""))
steam_email_input.text = external_verified_email
steam_email_input.editable = external_verified_email.is_empty()
_set_mode("steam")
+20 -11
View File
@@ -3,7 +3,6 @@ extends Control
const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
var screens := {}
var _account_profile_synced := false
@@ -30,7 +29,6 @@ var drawer_open := false
var menu_button: Button
var account_button: Button
var account_chevron_button: Button
var credits_badge: PanelContainer
var account_menu: PanelContainer
var nav_buttons: Dictionary = {}
var current_nav_key := "home"
@@ -206,9 +204,6 @@ func _build_ui() -> void:
_style_account_chevron(account_chevron_button)
header.add_child(account_chevron_button)
credits_badge = CreditsBadge.new()
header.add_child(credits_badge)
var screens_container := MarginContainer.new()
screens_margin = screens_container
screens_host = screens_container
@@ -355,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"
@@ -374,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"
@@ -431,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)
@@ -476,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()
@@ -494,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, ""))
@@ -774,7 +783,6 @@ func _on_auth_changed() -> void:
if not _account_profile_synced:
_account_profile_synced = true
AccountProfileService.refresh()
CreditsService.refresh()
else:
_account_profile_synced = false
if AuthState.is_logged_in and AuthState.requires_legal_acceptance and current_screen_name not in ["login", "legal_consent"]:
@@ -803,8 +811,6 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
_style_account_avatar(account_button)
if account_chevron_button != null:
_style_account_chevron(account_chevron_button)
if credits_badge != null:
credits_badge.refresh_theme_controls()
if sidebar != null:
sidebar.add_theme_stylebox_override("panel", _sidebar_box())
if header_panel != null:
@@ -822,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()
@@ -904,7 +913,7 @@ func _build_app_menu() -> void:
menu_exit_button.text = "Exit Run"
menu_exit_button.custom_minimum_size = Vector2(0, 48)
UISoundService.set_button_sound(menu_exit_button, "back")
menu_exit_button.pressed.connect(exit_current_run)
menu_exit_button.pressed.connect(_exit_run)
content.add_child(menu_exit_button)
var logout := Button.new()
@@ -951,7 +960,7 @@ func _navigate_from_pause(target_screen: String) -> void:
BlockSessionService.pause_current_question_timer()
func exit_current_run() -> void:
func _exit_run() -> void:
_close_app_menu()
if current_screen_name == "ccs_player":
var ccs_player = screens.get("ccs_player")
+2
View File
@@ -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
+25 -120
View File
@@ -4,7 +4,6 @@ const Style := preload("res://scripts/ui/ui_style.gd")
const Brand := preload("res://scripts/ui/medmind_style.gd")
const FlashcardPrefillDialog := preload("res://scripts/ui/flashcard_prefill_dialog.gd")
const CalculatorPanel := preload("res://scripts/ui/calculator_panel.gd")
const CreditsBadge := preload("res://scripts/ui/credits_badge.gd")
const CLEAR_HIGHLIGHT_DARK_ICON := preload("res://assets/icons/medmind/clear-highlight-dark.svg")
const CLEAR_HIGHLIGHT_LIGHT_ICON := preload("res://assets/icons/medmind/clear-highlight-light.svg")
const LABS_DARK_ICON := preload("res://assets/icons/medmind/labs-dark.svg")
@@ -15,7 +14,6 @@ const FLAG_DARK_ICON := preload("res://assets/icons/medmind/flag-dark.svg")
const FLAG_LIGHT_ICON := preload("res://assets/icons/medmind/flag-light.svg")
const BADGE_CIRCLE_OUTLINE_ICON := preload("res://assets/icons/medmind/badge-circle-outline.svg")
const BADGE_CIRCLE_FILLED_ICON := preload("res://assets/icons/medmind/badge-circle-filled.svg")
const COIN_ICON := preload("res://assets/icons/lucide/star.svg")
const FEEDBACK_FADE_SECONDS := 0.45
const CORRECT_ADVANCE_DELAY_SECONDS := 1.25
const TEXT_SCALE_MIN := 0.85
@@ -60,8 +58,6 @@ var stem_plain_text := ""
var explanation_plain_text := ""
var highlight_drag_source := ""
var timer_color_state := ""
var last_timer_bar_value := -1.0
var last_timer_display_seconds := -1
var pending_correct_advance := false
var question_text_scale := 1.0
var presented_choices: Array = []
@@ -94,7 +90,6 @@ var text_smaller_button: Button
var text_larger_button: Button
var labs_button: Button
var calculator_button: Button
var exit_button: Button
var clear_highlight_button: Button
var flag_button: Button
var report_button: Button
@@ -104,7 +99,6 @@ var flashcard_prefill_dialog: Control
var bookmark_button: Button
var app_feedback_button: Button
var theme_button: Button
var credits_badge: PanelContainer
var question_toolbar: HFlowContainer
var header_stack: VBoxContainer
var header_row: HBoxContainer
@@ -323,18 +317,6 @@ func _ready() -> void:
UISoundService.set_button_sound(text_larger_button, "toggle")
text_larger_button.pressed.connect(func(): _adjust_question_text_scale(TEXT_SCALE_STEP))
exit_button = Button.new()
exit_button.name = "ExitButton"
exit_button.tooltip_text = "Return to main area"
exit_button.icon = preload("res://assets/icons/lucide/house.svg")
exit_button.custom_minimum_size = Vector2(44, 44)
exit_button.add_theme_constant_override("icon_max_width", 22)
exit_button.alignment = HORIZONTAL_ALIGNMENT_CENTER
exit_button.icon_alignment = HORIZONTAL_ALIGNMENT_CENTER
exit_button.focus_mode = Control.FOCUS_NONE
UISoundService.set_button_sound(exit_button, "back")
exit_button.pressed.connect(func(): main.exit_current_run())
calculator_button = Button.new()
calculator_button.name = "CalculatorButton"
calculator_button.tooltip_text = "Open calculator"
@@ -422,13 +404,10 @@ func _ready() -> void:
Style.style_chip(app_feedback_button, Style.CYAN)
app_feedback_button.pressed.connect(func(): main.open_feedback_dialog())
credits_badge = CreditsBadge.new()
# Every action is directly visible - no "More" overflow or popups hiding
# controls behind an extra click. No text labels either (their varying widths
# broke even spacing when a button hid itself) - each button's existing
# tooltip_text carries the label as hover text instead.
question_toolbar.add_child(exit_button)
question_toolbar.add_child(text_smaller_button)
question_toolbar.add_child(text_larger_button)
question_toolbar.add_child(calculator_button)
@@ -441,7 +420,6 @@ func _ready() -> void:
question_toolbar.add_child(notes_button)
question_toolbar.add_child(theme_button)
question_toolbar.add_child(app_feedback_button)
question_toolbar.add_child(credits_badge)
header_toolbar_host = HBoxContainer.new()
header_toolbar_host.alignment = BoxContainer.ALIGNMENT_END
@@ -489,7 +467,6 @@ func _ready() -> void:
content_margin.size_flags_vertical = Control.SIZE_EXPAND_FILL
content_margin.add_theme_constant_override("margin_left", 4)
content_margin.add_theme_constant_override("margin_right", 12)
content_margin.add_theme_constant_override("margin_top", 20)
question_scroll.add_child(content_margin)
question_content = VBoxContainer.new()
@@ -810,10 +787,9 @@ func _refresh_responsive_layout() -> void:
header_toolbar_host.reparent(target_parent)
header_toolbar_host.size_flags_horizontal = Control.SIZE_EXPAND_FILL if compact else Control.SIZE_SHRINK_END
question_toolbar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
# 12 bare 44px icon buttons + the wider 92px Feedback button + the ~194px
# rank/ELO + points badge, with 8px gaps between all 14 - anything less
# than this wraps onto a second row.
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 960.0
# 11 bare 44px icon buttons + the wider 92px Feedback button, with 8px gaps
# between all 12 - anything less than this wraps onto a second row.
header_toolbar_host.custom_minimum_size.x = 0.0 if compact else 680.0
if size.x < 560.0:
nav_palette.columns = 5
else:
@@ -1069,7 +1045,7 @@ func _apply_question_theme() -> void:
var navigator_heading := navigator_popup.find_child("NavigatorHeading", true, false) as Label
if navigator_heading != null:
navigator_heading.add_theme_color_override("font_color", Brand.text_color(dark))
for control in [exit_button, text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
for control in [text_smaller_button, text_larger_button, calculator_button, bookmark_button, notes_button, flashcard_button, app_feedback_button]:
if control == null:
continue
var active: bool = control == bookmark_button and BookmarkService.is_bookmarked(str(BlockSessionService.get_current_question().get("id", "")))
@@ -1085,8 +1061,6 @@ func _apply_question_theme() -> void:
_style_theme_toggle_button(dark)
if calculator_panel != null:
calculator_panel.refresh_theme_controls()
if credits_badge != null:
credits_badge.refresh_theme_controls()
if skip_button != null:
var skip_link_color := Brand.SKY_BLUE if dark else Brand.ROYAL_BLUE
skip_button.add_theme_stylebox_override("normal", StyleBoxEmpty.new())
@@ -1270,8 +1244,6 @@ func _load_question() -> void:
flagged = false
selected_choice = ""
current_answer = {}
last_timer_bar_value = -1.0
last_timer_display_seconds = -1
flag_button.button_pressed = false
_refresh_toolbar_state()
_clear_answer_review()
@@ -1333,7 +1305,7 @@ func _load_question() -> void:
if category_text.is_empty():
category_text = str(question.get("system", "")).strip_edges()
category_label.text = category_text
category_label.visible = false
category_label.visible = not category_text.is_empty()
_refresh_labs_button()
presented_choices = question.get("presented_choices", question.get("choices", [])).duplicate(true)
@@ -1456,12 +1428,15 @@ func _skip_current_question() -> void:
return
if BlockSessionService.session_kind != "core":
if BlockSessionService.session_kind == "simulation":
selected_choice = ""
current_answer = {}
_save_simulation_current_response()
var target_index := _next_pending_simulation_index() if BlockSessionService.session_kind == "simulation" else _next_revisitable_index()
var target_index := _next_revisitable_index()
if target_index >= 0 and BlockSessionService.move_to(target_index):
_load_question()
elif BlockSessionService.session_kind == "simulation" and not simulation_submit_pending:
simulation_submit_pending = true
next_button.disabled = true
next_button.text = "Submitting..."
ExamSimulationService.submit_block()
else:
_finish()
return
@@ -1493,18 +1468,6 @@ func _next_revisitable_index() -> int:
return (BlockSessionService.current_index + 1) % question_count
func _next_pending_simulation_index() -> int:
var question_count := BlockSessionService.active_questions.size()
if question_count == 0:
return -1
for offset in range(1, question_count + 1):
var candidate := (BlockSessionService.current_index + offset) % question_count
var answer = BlockSessionService.get_answer_for_index(candidate)
if answer == null or str(answer.get("selected_choice_id", "")) == "":
return candidate
return -1
func _choice_gui_input(event: InputEvent, index: int) -> void:
if pending_correct_advance or followup_required or _is_correct_answer_locked():
return
@@ -1552,15 +1515,12 @@ func _toggle_eliminate_selected() -> void:
func _submit_or_next() -> void:
if BlockSessionService.session_kind == "simulation":
if selected_choice == "":
return
_save_simulation_current_response()
var pending_index := _next_pending_simulation_index()
if pending_index >= 0:
BlockSessionService.move_to(pending_index)
if BlockSessionService.current_index + 1 < BlockSessionService.active_questions.size():
BlockSessionService.move_next()
_load_question()
return
if not simulation_submit_pending:
elif not simulation_submit_pending:
simulation_submit_pending = true
next_button.disabled = true
next_button.text = "Submitting..."
@@ -1582,15 +1542,12 @@ func _submit_or_next() -> void:
return
if BlockSessionService.session_kind in ["core", "bookmark", "review"]:
var attempted_choice := selected_choice
var met_time_limit := true
if BlockSessionService.session_kind == "core":
met_time_limit = BlockSessionService.get_current_elapsed_seconds() <= BlockSessionService.core_current_target_seconds
QuestionBankService.record_first_attempt(
question_id_for_attempt,
attempted_choice,
_presented_choice_ids(),
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())),
met_time_limit
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))
)
var result: Dictionary
if BlockSessionService.session_kind == "core":
@@ -1613,15 +1570,6 @@ func _submit_or_next() -> void:
_update_question_elo_label(true)
_show_answer_review()
_trigger_correct_feedback()
var earned_coin := (
BlockSessionService.session_kind == "core"
and bool(result.get("mastered", false))
and met_time_limit
)
if earned_coin:
var correct_choice_index := _choice_index_for_id(selected_choice)
if correct_choice_index >= 0 and correct_choice_index < choice_rows.size():
_play_coin_burst(choice_rows[correct_choice_index].get_global_rect())
return
_lock_wrong_choice(attempted_choice)
_show_wrong_answer_followup(attempted_choice)
@@ -1722,9 +1670,6 @@ func _on_random_question_failed(message: String) -> void:
func _show_answer_review() -> void:
if BlockSessionService.session_kind == "simulation":
_clear_answer_review()
return
_show_explanation()
_show_references(BlockSessionService.get_current_question().get("references", []))
_ensure_answer_review_followups()
@@ -1771,7 +1716,6 @@ func _show_explanation() -> void:
explanation_plain_text = str(question.get("explanation", ""))
_render_explanation()
explanation_label.visible = true
category_label.visible = not category_label.text.strip_edges().is_empty()
_update_question_elo_label(true)
_refresh_nav_palette()
_update_content_vertical_alignment()
@@ -1796,8 +1740,6 @@ func _clear_answer_review() -> void:
explanation_label.visible = false
if correct_review_panel != null:
correct_review_panel.visible = false
if category_label != null:
category_label.visible = false
_clear_container_children(teaching_cards)
_clear_container_children(review_followups_content)
review_followup_states.clear()
@@ -1809,7 +1751,13 @@ func _clear_answer_review() -> void:
func _update_content_vertical_alignment() -> void:
if question_content == null:
return
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN
# Before an answer is submitted the stem+choices block is usually shorter than the
# available panel height; center it so it doesn't sit pinned to the top with a slab
# of empty space below. Once the answer review content appears, go back to top
# alignment so the growing explanation/follow-up sections read top-down and scroll
# naturally instead of being centered mid-panel.
var answered := correct_review_panel != null and correct_review_panel.visible
question_content.alignment = BoxContainer.ALIGNMENT_BEGIN if answered else BoxContainer.ALIGNMENT_CENTER
func _clear_container_children(container: Control) -> void:
@@ -1863,9 +1811,6 @@ func _render_review_followups() -> void:
if review_followups_content == null:
return
_clear_container_children(review_followups_content)
if BlockSessionService.session_kind == "simulation":
review_followups_section.visible = false
return
if not BlockSessionService.show_review_followups:
review_followups_section.visible = false
return
@@ -2098,10 +2043,7 @@ func _update_timer_bar() -> void:
return
var is_core_timer := BlockSessionService.session_kind == "core"
var ratio := BlockSessionService.get_session_timer_ratio()
var next_value := ratio * 100.0
if last_timer_bar_value < 0.0 or absf(next_value - last_timer_bar_value) >= 0.1:
last_timer_bar_value = next_value
timer_bar.value = next_value
timer_bar.value = ratio * 100.0
if is_core_timer and timer_label.visible:
_set_timer_display(int(floor(BlockSessionService.get_core_question_total_elapsed_seconds())))
var color_state := "blue"
@@ -2121,9 +2063,6 @@ func _set_timer_display(total_seconds: int) -> void:
if timer_label == null:
return
var safe_seconds := maxi(0, total_seconds)
if safe_seconds == last_timer_display_seconds:
return
last_timer_display_seconds = safe_seconds
timer_label.text = "%02d:%02d" % [int(floor(float(safe_seconds) / 60.0)), safe_seconds % 60]
@@ -2226,39 +2165,6 @@ func _trigger_correct_feedback() -> void:
feedback_fog.color = Color(0.25, 1.0, 0.25, _feedback_overlay_alpha(true))
func _play_coin_burst(origin_rect: Rect2) -> void:
if credits_badge == null:
return
var origin := origin_rect.get_center()
var target := credits_badge.get_global_rect().get_center()
var coin_count := 6
var flight_time := 0.55
var stagger := 0.035
for index in range(coin_count):
var coin := TextureRect.new()
coin.texture = COIN_ICON
coin.modulate = Brand.AMBER
coin.custom_minimum_size = Vector2(16, 16)
coin.size = Vector2(16, 16)
coin.top_level = true
coin.mouse_filter = Control.MOUSE_FILTER_IGNORE
coin.pivot_offset = Vector2(8, 8)
var jitter := Vector2(randf_range(-24.0, 24.0), randf_range(-16.0, 16.0))
coin.global_position = origin + jitter - coin.size * 0.5
add_child(coin)
var tween := create_tween()
tween.tween_interval(index * stagger)
tween.set_parallel(true)
tween.tween_property(coin, "global_position", target - coin.size * 0.5, flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
tween.tween_property(coin, "scale", Vector2(0.6, 0.6), flight_time).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN)
tween.set_parallel(false)
tween.tween_callback(coin.queue_free)
var settle_time := (coin_count - 1) * stagger + flight_time
get_tree().create_timer(settle_time).timeout.connect(func():
if credits_badge != null:
credits_badge.bump())
func _update_answer_feedback(delta: float) -> void:
if shake_time > 0.0:
shake_time = max(0.0, shake_time - delta)
@@ -2373,7 +2279,7 @@ func _refresh_toolbar_state() -> void:
if notes_button != null:
notes_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
if flashcard_button != null:
flashcard_button.visible = Brand.is_uat_environment() and BlockSessionService.session_kind not in ["simulation", "dynamic"]
flashcard_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
flashcard_button.disabled = current_answer.is_empty()
if bookmark_button != null:
bookmark_button.visible = BlockSessionService.session_kind not in ["simulation", "dynamic"]
@@ -3355,8 +3261,7 @@ func _refresh_submit_state() -> void:
if next_button == null:
return
if BlockSessionService.session_kind == "simulation":
next_button.disabled = selected_choice == ""
next_button.text = "Submit Block" if selected_choice != "" and _next_pending_simulation_index() < 0 else "Next"
next_button.disabled = false
return
if pending_correct_advance or followup_required:
next_button.disabled = true
+16 -2
View File
@@ -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]
-23
View File
@@ -22,7 +22,6 @@ func _initialize() -> void:
_test_feedback_context_excludes_study_content()
_test_feedback_form_validation()
_test_revisitable_navigation_prefers_unanswered_questions()
_test_timer_text_avoids_redundant_frame_updates()
_test_submit_styles_keep_stable_geometry()
_test_core_timer_counts_up_and_remains_visible()
await _test_player_runtime_construction()
@@ -342,34 +341,12 @@ func _test_revisitable_navigation_prefers_unanswered_questions() -> void:
session.answers[1] = {"selected_choice_id": "B"}
session.current_index = 2
_assert(player._next_revisitable_index() == 0, "skip should wrap rather than reload the same unanswered question")
session.answers = [{"selected_choice_id": "A"}, null, {"selected_choice_id": "C"}]
session.current_index = 2
_assert(player._next_pending_simulation_index() == 1, "simulation should cycle an earlier skipped question back")
session.answers[1] = {"selected_choice_id": "B"}
_assert(player._next_pending_simulation_index() == -1, "simulation should submit only after every question has an answer")
session.answers[2] = {"selected_choice_id": ""}
session.current_index = 2
_assert(player._next_pending_simulation_index() == 2, "the only skipped simulation question should remain pending")
player.free()
session.active_questions = prior_questions
session.answers = prior_answers
session.current_index = prior_index
func _test_timer_text_avoids_redundant_frame_updates() -> void:
var player = load("res://scripts/ui/question_player.gd").new()
player.timer_label = Label.new()
player._set_timer_display(65)
_assert(player.timer_label.text == "01:05", "timer should render a changed second")
player.timer_label.text = "unchanged"
player._set_timer_display(65)
_assert(player.timer_label.text == "unchanged", "timer should not rewrite its label every frame")
player._set_timer_display(66)
_assert(player.timer_label.text == "01:06", "timer should render the next changed second")
player.timer_label.free()
player.free()
func _test_player_runtime_construction() -> void:
var host := Control.new()
root.add_child(host)
-230
View File
@@ -1,230 +0,0 @@
#!/usr/bin/env python3
"""Audit or backfill missing question lab reference ranges.
Ranges are based primarily on the NBME Laboratory Reference Values published
April 2026. The command is dry-run by default and writes a recoverable MongoDB
backup plus an audit record before applying changes.
"""
import argparse
import copy
import uuid
from datetime import datetime, timezone
from pymongo import MongoClient
NBME = {
"sodium": "136–146 mEq/L", "serum sodium": "136–146 mEq/L",
"potassium": "3.5–5.0 mEq/L", "serum potassium": "3.5–5.0 mEq/L",
"chloride": "95–105 mEq/L", "serum chloride": "95–105 mEq/L",
"bicarbonate": "22–28 mEq/L", "serum bicarbonate": "22–28 mEq/L", "hco3-": "22–28 mEq/L",
"blood urea nitrogen": "7–18 mg/dL", "serum urea nitrogen": "7–18 mg/dL",
"urea nitrogen": "7–18 mg/dL", "bun": "7–18 mg/dL",
"creatinine": "0.6–1.2 mg/dL", "serum creatinine": "0.6–1.2 mg/dL",
"baseline creatinine": "0.6–1.2 mg/dL", "postprocedure day 1 creatinine": "0.6–1.2 mg/dL",
"postprocedure day 2 creatinine": "0.6–1.2 mg/dL",
"glucose": "Fasting 70–100; random <140 mg/dL", "serum glucose": "Fasting 70–100; random <140 mg/dL",
"fasting glucose": "70–100 mg/dL", "fingerstick glucose": "70–100 mg/dL",
"point-of-care glucose": "70–100 mg/dL", "plasma glucose during episode": "70–100 mg/dL fasting",
"calcium": "8.4–10.2 mg/dL", "magnesium": "1.5–2.0 mg/dL",
"phosphorus": "3.0–4.5 mg/dL", "albumin": "3.5–5.5 g/dL",
"ast": "12–38 U/L", "aspartate aminotransferase": "12–38 U/L",
"alt": "10–40 U/L", "alanine aminotransferase": "10–40 U/L",
"alkaline phosphatase": "25–100 U/L", "amylase": "25–125 U/L", "lipase": "13–60 U/L",
"total bilirubin": "0.1–1.0 mg/dL", "serum bilirubin": "0.1–1.0 mg/dL",
"direct bilirubin": "0.0–0.3 mg/dL", "lactate dehydrogenase": "45–200 U/L",
"serum osmolality": "275–295 mOsm/kg H2O", "serum uric acid": "3.0–8.2 mg/dL",
"creatine kinase": "Male 25–90; female 10–70 U/L",
"total cholesterol": "Normal <200 mg/dL", "ldl cholesterol": "<160 mg/dL",
"ldl-cholesterol": "<160 mg/dL", "hdl cholesterol": "40–60 mg/dL",
"triglycerides": "Normal <150 mg/dL", "serum ferritin": "Male 20–250; female 10–120 ng/mL",
"tsh": "0.4–4.0 mIU/L", "thyroid-stimulating hormone": "0.4–4.0 mIU/L",
"free t4": "0.9–1.7 ng/dL", "total t3": "100–200 ng/dL",
"serum ige": "0–380 IU/mL", "ph": "7.35–7.45", "pco2": "33–45 mm Hg",
"paco2": "33–45 mm Hg", "arterial pco2": "33–45 mm Hg",
"pao2": "75–105 mm Hg", "po2": "75–105 mm Hg", "arterial po2": "75–105 mm Hg",
"arterial ph": "7.35–7.45",
"hemoglobin": "Male 13.5–17.5; female 12.0–16.0 g/dL",
"hematocrit": "Male 41%–53%; female 36%–46%",
"mean corpuscular volume": "80–100 fL", "mcv": "80–100 fL",
"leukocyte count": "4,500–11,000/mm3", "white blood cell count": "4,500–11,000/mm3",
"wbc count": "4,500–11,000/mm3", "wbc": "4,500–11,000/mm3",
"platelet count": "150,000–400,000/mm3", "platelets": "150,000–400,000/mm3",
"segmented neutrophils": "54%–62%", "band neutrophils": "3%–5%",
"lymphocytes": "25%–33%", "blood eosinophils": "1%–3%",
"pt": "11–15 seconds", "prothrombin time": "11–15 seconds",
"ptt": "25–40 seconds", "activated partial thromboplastin time": "25–40 seconds",
"inr": "0.8–1.2", "d-dimer": "≤250 ng/mL", "fibrinogen": "200–400 mg/dL",
"reticulocyte count": "0.5%–1.5%", "cd4 count": "≥500/mm3",
"hemoglobin a1c": "≤6%", "erythrocyte sedimentation rate": "Male 0–15; female 0–20 mm/h",
"urine osmolality": "50–1200 mOsm/kg H2O", "urine specific gravity": "1.005–1.030",
"urine albumin/creatinine ratio": "<30 mg/g", "urine albumin-to-creatinine ratio": "<30 mg/g",
"urine albumin-creatinine ratio": "<30 mg/g", "urine protein": "Negative; total <150 mg/24 h",
"urine wbc": "0–5/hpf", "urine wbcs": "0–5/hpf", "urine white blood cells": "0–5/hpf",
"urine leukocytes": "0–5/hpf", "urine rbc": "0–2/hpf", "urine rbcs": "0–2/hpf",
"urine erythrocytes": "0–2/hpf", "rbcs": "0–2/hpf", "wbcs": "0–5/hpf",
"bmi": "Adult 19–25 kg/m2", "cd4+ t-lymphocyte count": "≥500/mm3",
"c-reactive protein": "<1.0 mg/dL", "lactate": "0.5–2.2 mmol/L",
"vitamin b12": "200–900 pg/mL", "haptoglobin": "30–200 mg/dL",
"25-hydroxyvitamin d": "20–50 ng/mL", "aldolase": "1.0–7.5 U/L",
"bnp": "<100 pg/mL", "high-sensitivity troponin i": "Assay-specific; typically <99th percentile",
"oxygen saturation": "95%–100%", "room-air oxygen saturation": "95%–100%",
"temperature": "36.5–37.5°C", "pulse": "60–100/min", "respirations": "12–20/min",
"blood pressure": "Adult <120/80 mm Hg", "left ventricular ejection fraction": "55%–70%",
"qrs duration": "≤120 milliseconds", "pr interval": "120–200 milliseconds",
"estimated glomerular filtration rate": "≥60 mL/min/1.73 m2", "estimated gfr": "≥60 mL/min/1.73 m2",
"salicylate": "Therapeutic 10–30 mg/dL", "c3": "90–180 mg/dL", "c4": "10–40 mg/dL",
"c1q": "12–22 mg/dL", "synovial fluid leukocyte count": "<200/mm3",
}
QUALITATIVE = {
"urinalysis", "peripheral blood smear", "serial cardiac troponins", "resting ecg between episodes",
"exercise treadmill test", "serum electrolytes", "urinalysis - leukocyte esterase",
"urinalysis - nitrites", "urinalysis - white blood cells", "compression ultrasonography",
"stool occult blood", "gastric biopsy", "urine toxicology screen", "repeat liver studies",
"synovial fluid gram stain", "synovial fluid bacterial culture", "synovial fluid crystal analysis",
"pulmonary function testing", "chest ct", "adrenal ct", "urine beta-hcg", "lithium level",
"serum beta-hydroxybutyrate", "antistreptolysin o titer", "fev1", "cbc",
"complete blood count", "comprehensive metabolic panel", "ecg", "echocardiogram",
"maternal hepatitis b surface antigen", "leukocyte esterase", "nitrite", "bacteria",
"urine blood", "urine leukocyte esterase", "urine nitrite", "urine casts",
"urine dipstick blood", "pregnancy test", "urine pregnancy test",
"potassium hydroxide preparation from scale scraping", "fetal karyotype",
"glucose and electrolytes", "cbc, ferritin, zinc, and venous lead",
}
CUSTOM = {
"urine sodium": "40–220 mEq/24 h (diet dependent)",
"hiv rna": "Not detected",
"urine erythrocytes": "0–2/hpf", "urine leukocytes": "0–5/hpf",
"urine calcium": "100–300 mg/24 h", "urine blood": "Negative",
"urine nitrite": "Negative", "urine leukocyte esterase": "Negative",
"urine dipstick blood": "Negative", "pregnancy test": "Negative",
"urine pregnancy test": "Negative", "urine beta-hcg": "Negative",
"urine casts": "None to rare hyaline casts",
"urine albumin-creatinine ratio": "<30 mg/g",
"urine albumin-to-creatinine ratio": "<30 mg/g",
"urine albumin/creatinine ratio": "<30 mg/g",
"urine protein": "Negative; total <150 mg/24 h",
"ascitic fluid albumin": "Interpret with serum albumin (SAAG)",
"ascitic fluid total protein": "Clinical-context dependent",
"home glucose values": "Individualized target; commonly 80–130 mg/dL premeal",
"oxygen saturation": "95%–100%", "estimated pulmonary artery systolic pressure": "15–30 mm Hg",
"pulmonary-to-systemic flow ratio (qp:qs)": "Approximately 1:1",
"glucose infusion rate": "Age- and clinical-context dependent",
"tuberculin skin test": "Positive at ≥5, ≥10, or ≥15 mm depending on risk",
"urine output": "Adult target ≥0.5 mL/kg/h",
}
def embedded_test(value):
lowered = value.lower().strip()
aliases = [
("thyroid-stimulating hormone", "thyroid-stimulating hormone"), ("complete metabolic panel", None),
("complete blood count", None), ("liver function tests", None), ("erythrocyte sedimentation rate", "erythrocyte sedimentation rate"),
("measured serum osmolality", "serum osmolality"), ("activated partial thromboplastin time", "activated partial thromboplastin time"),
("platelet count", "platelet count"), ("white blood cell count", "white blood cell count"),
("blood urea nitrogen", "blood urea nitrogen"), ("oxygen saturation", "oxygen saturation"),
("prothrombin time", "prothrombin time"),
("segmented neutrophils", "segmented neutrophils"), ("creatine kinase", "creatine kinase"),
("hematocrit", "hematocrit"), ("hemoglobin", "hemoglobin"), ("leukocyte count", "leukocyte count"),
("arterial ph", "ph"), ("serum lipase", "lipase"), ("serum albumin", "albumin"),
("total cholesterol", "total cholesterol"), ("urine output", "urine output"),
("tuberculin skin test induration", "tuberculin skin test"), ("hiv rna", "hiv rna"),
("cd4 count", "cd4 count"), ("pco2", "pco2"), ("po2", "po2"), ("sodium", "sodium"),
("potassium", "potassium"), ("chloride", "chloride"), ("bicarbonate", "bicarbonate"),
("hco3", "bicarbonate"), ("glucose", "glucose"), ("creatinine", "creatinine"),
("calcium", "calcium"), ("magnesium", "magnesium"), ("phosphorus", "phosphorus"),
("albumin", "albumin"), ("lipase", "lipase"), ("bands", "band neutrophils"),
("lymphocytes", "lymphocytes"), ("inr", "inr"), ("na ", "sodium"), ("cl ", "chloride"),
("k ", "potassium"), ("bun ", "bun"),
]
for prefix, key in aliases:
if lowered.startswith(prefix) or lowered.startswith("admission " + prefix) or lowered.startswith("repeat " + prefix):
return key
return None
def reference_for(name, value):
key = name.strip().lower()
if key == "laboratory result":
key = embedded_test(value)
if key is None:
if " normal" in value.lower() or "within reference range" in value.lower():
return "Normal/within the laboratory reference interval"
return None
if key in CUSTOM:
return CUSTOM[key]
if key in NBME:
return NBME[key]
if key in QUALITATIVE:
return "Expected: normal/negative unless otherwise specified"
return None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True)
parser.add_argument("--database", required=True)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
client = MongoClient(args.uri, serverSelectionTimeoutMS=5000)
db = client[args.database]
questions = db["Questions"]
changes = []
unresolved = []
for question in questions.find({"Labs.0": {"$exists": True}}):
labs = question.get("Labs") or []
original_labs = copy.deepcopy(labs)
updated = False
for lab in labs:
if (lab.get("Reference") or "").strip():
continue
reference = reference_for(str(lab.get("Name") or ""), str(lab.get("Value") or ""))
if reference is None:
unresolved.append((question.get("ExternalId"), lab.get("Name"), lab.get("Value")))
else:
lab["Reference"] = reference
updated = True
if updated:
changes.append((question, original_labs, labs))
print(f"database={args.database} questions_to_update={len(changes)} unresolved_labs={len(unresolved)}")
for item in unresolved:
print(f"UNRESOLVED externalId={item[0]!r} name={item[1]!r} value={item[2]!r}")
if unresolved:
raise SystemExit("Refusing to apply while lab entries remain unresolved")
if not args.apply:
return
batch_id = f"lab-reference-{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-{uuid.uuid4().hex[:8]}"
backups = db["QuestionLabReferenceBackups"]
audit = db["ContentMaintenance"]
now = datetime.now(timezone.utc)
for question, original_labs, labs in changes:
backups.insert_one({
"BatchId": batch_id, "QuestionId": question["_id"], "ExternalId": question.get("ExternalId"),
"Labs": original_labs, "BackedUpUtc": now,
})
questions.update_one({"_id": question["_id"]}, {"$set": {"Labs": labs, "UpdatedUtc": now}})
remaining = questions.count_documents({
"IsPublished": True,
"Labs": {"$elemMatch": {"$or": [
{"Reference": {"$exists": False}}, {"Reference": ""}, {"Reference": None}
]}}
})
audit.insert_one({
"BatchId": batch_id, "Operation": "BackfillQuestionLabReferences",
"Database": args.database, "QuestionsUpdated": len(changes),
"RemainingPublishedQuestionsWithMissingReferences": remaining, "CompletedUtc": now,
"Source": "NBME Laboratory Reference Values (April 2026)",
})
print(f"applied batch={batch_id} questions_updated={len(changes)} remaining_published={remaining}")
if remaining:
raise SystemExit("Post-update verification failed")
if __name__ == "__main__":
main()