Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions middlewareNode/src/models/actionEvent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* ActionEvent Schema
*
* Append-only raw log of "something happened that might earn currency" —
* one document per real-world action (a lesson completed, a puzzle solved),
* written durably before anything decides whether it pays out.
*
* This is a plain MongoDB collection, not a message broker (see Rev. 2 of
* the currency rollout plan, "Drop Redis from this window"): nothing here
* needs consumer groups or broker-grade throughput at current scale, and a
* collection gets the same durability/replay/audit properties for free,
* with no new ops dependency.
*
* `eventId` is unique, which is the idempotency key all the way down the
* pipeline — the same pattern GameResults uses `gameId` for. A producer
* (or the historical backfill) can safely retry an insert; a duplicate
* `eventId` is rejected by the unique index rather than silently
* double-processed.
*
* `status` drives the consumer: it claims a batch of "pending" events,
* processes them, and marks each "processed" or "failed" so a crash
* mid-batch never loses or silently re-skips work. See
* services/currencyConsumer.js.
*/

const mongoose = require("mongoose");

const ActionEventSchema = new mongoose.Schema(
{
// Idempotency key. Producers mint this deterministically (e.g.
// `lesson:<userId>:<lessonId>:<completedAt-ms>`); the historical
// backfill mints synthetic ones (e.g. `backfill:lesson:<userId>:<piece>:<n>`)
// so it is safely re-runnable against the same source data.
eventId: { type: String, required: true, unique: true, index: true },

// Matches an ActionRule.actionKey — e.g. "lesson.completed", "puzzle.solved".
actionKey: { type: String, required: true, index: true },

userId: { type: mongoose.Schema.Types.ObjectId, required: true, index: true },

// Action-specific payload (lessonId, piece, difficulty, ...). Opaque to
// the consumer beyond what a given ActionRule's conditions inspect.
metadata: { type: mongoose.Schema.Types.Mixed, default: {} },

// Compound-indexed with actionKey below — the consumer's claim query
// and the cooldown/daily-cap checks both filter on status + userId +
// actionKey + occurredAt.
status: {
type: String,
enum: ["pending", "claimed", "processed", "failed"],
default: "pending",
index: true,
},

// Set once the consumer finishes with this event, whichever way.
processedAt: { type: Date, default: null },

// Populated only when status is "failed" — the rules-engine or ledger
// error that occurred, so a stuck event is diagnosable without log
// spelunking.
failureReason: { type: String, default: null },

// When the underlying action actually happened — not when this record
// was written. Backfilled events set this to the historical date so
// cooldown/daily-cap windows evaluate correctly against real history.
occurredAt: { type: Date, required: true, default: Date.now, index: true },
},
{ timestamps: true }
);

// The consumer's core query: "give me pending events for this action,
// oldest first." Also serves cooldown/daily-cap lookups scoped to a single
// user+action.
ActionEventSchema.index({ status: 1, actionKey: 1, occurredAt: 1 });
ActionEventSchema.index({ userId: 1, actionKey: 1, occurredAt: -1 });

module.exports = mongoose.model("ActionEvent", ActionEventSchema);
43 changes: 43 additions & 0 deletions middlewareNode/src/models/actionRule.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* ActionRule Schema
*
* The config layer that decides whether an ActionEvent pays out, and how
* much. Adding a new currency-earning action is meant to be "one emit line
* plus one ActionRule document" — zero code changes to the consumer (the
* plan's "action #47" test).
*
* No admin UI this window (deferred per the currency rollout plan) —
* these are created and edited directly against MongoDB.
*/

const mongoose = require("mongoose");

const ActionRuleSchema = new mongoose.Schema(
{
// Matches ActionEvent.actionKey. Unique — one rule per action.
actionKey: { type: String, required: true, unique: true, index: true },

// How much lifetimeEarned/balance an occurrence of this action pays out.
currencyAmount: { type: Number, required: true, min: 0 },

// A disabled rule's events are still logged (ActionEvent is
// append-only) but never produce a ledger entry — lets an action be
// turned off without losing the underlying history.
active: { type: Boolean, default: true, index: true },

// Minimum seconds between two payouts for the same user+action. 0
// disables cooldown enforcement for this action.
cooldownSeconds: { type: Number, default: 0, min: 0 },

// Max payouts per user+action per UTC day. 0 disables the cap.
dailyCap: { type: Number, default: 0, min: 0 },

// Optional free-form notes on why the rule exists / its current amount
// — this collection has no admin UI, so this is the only place that
// context lives outside a commit message.
notes: { type: String, default: "" },
},
{ timestamps: true }
);

module.exports = mongoose.model("ActionRule", ActionRuleSchema);
53 changes: 53 additions & 0 deletions middlewareNode/src/models/ledgerEntry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* LedgerEntry Schema
*
* Append-only, source of truth for currency balance. Never a running
* total — every award is its own immutable row, the same computed-on-read
* philosophy GameResults uses for chess record/score (see
* models/gameResults.js): change how points are computed and every
* historical entry is still exactly what it says, with nothing to drift
* or backfill in the ledger itself.
*
* `eventId` carries the same value as the ActionEvent it was created from
* and is unique here too — this is the actual idempotency guarantee for
* the whole pipeline. The consumer's insert either succeeds once or fails
* with a duplicate-key error that it treats as "already processed,
* nothing to do" (mirroring gameResults.js's `err.code === 11000` handling),
* so a re-delivered or retried event can never double-pay.
*
* Amounts are positive-only this window — no spend path exists yet
* (deferred per the currency rollout plan). UserBalance.lifetimeEarned is
* the running sum of these entries; the field is named lifetimeEarned
* rather than balance so that when a spend path does ship, ranking logic
* that already reads lifetimeEarned doesn't start rewarding students for
* not spending.
*/

const mongoose = require("mongoose");

const LedgerEntrySchema = new mongoose.Schema(
{
// Idempotency key — matches the source ActionEvent.eventId exactly.
eventId: { type: String, required: true, unique: true, index: true },

userId: { type: mongoose.Schema.Types.ObjectId, required: true, index: true },

actionKey: { type: String, required: true, index: true },

// Positive-only this window. Kept as a plain Number (not unsigned) so
// a future spend path is a validation change, not a schema migration.
amount: { type: Number, required: true },

// When the underlying action happened (copied from ActionEvent.occurredAt),
// not when this row was written — a backfilled entry's history should
// read as history, not as having happened at backfill time.
occurredAt: { type: Date, required: true, index: true },
},
{ timestamps: true }
);

// A user's full ledger history, newest first — the query the future spend
// path and any per-student ledger view will both want.
LedgerEntrySchema.index({ userId: 1, occurredAt: -1 });

module.exports = mongoose.model("LedgerEntry", LedgerEntrySchema);
42 changes: 42 additions & 0 deletions middlewareNode/src/models/userBalance.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* UserBalance Schema
*
* Read-optimized cache of a user's ledger position, updated alongside
* every LedgerEntry write. This collection is a cache, not a source of
* truth — LedgerEntry is; a UserBalance document can always be rebuilt by
* summing that user's LedgerEntry rows, which is exactly what the
* historical backfill's replay does on first run.
*
* Two fields, deliberately different meanings (see Rev. 2 change 3,
* "Rank by lifetime earned, not balance"):
*
* - balance: spendable total. Unused this window (no spend path
* exists yet) but present now so adding one later is
* a feature change, not a schema migration.
* - lifetimeEarned: monotonic sum of positive LedgerEntry amounts only.
* Never decreases. THIS is what the leaderboard reads
* — ranking by spendable balance would mean the top
* of the leaderboard is whichever student has
* redeemed the least, the moment a store ships.
*
* A user with no document here (hasn't earned anything yet) must be
* treated as lifetimeEarned: 0 by any reader — see
* getLifetimeEarnedOrZero in services/ledgerService.js — never as
* undefined, which would produce undefined sort ordering in the
* leaderboard's comparator.
*/

const mongoose = require("mongoose");

const UserBalanceSchema = new mongoose.Schema(
{
userId: { type: mongoose.Schema.Types.ObjectId, required: true, unique: true, index: true },

balance: { type: Number, required: true, default: 0 },

lifetimeEarned: { type: Number, required: true, default: 0, index: true },
},
{ timestamps: true }
);

module.exports = mongoose.model("UserBalance", UserBalanceSchema);
122 changes: 51 additions & 71 deletions middlewareNode/src/routes/leaderboard.js
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
/**
* Leaderboard Routes — /leaderboard
*
* Student-facing endpoint returning ranked students by a composite score.
* Protected by requireAuth (valid JWT, any role) — NOT admin-only, unlike
* /analytics, since students need to view the leaderboard themselves.
* Student-facing endpoint returning ranked students by their currency
* ledger standing. Protected by requireAuth (valid JWT, any role) — NOT
* admin-only, unlike /analytics, since students need to view the
* leaderboard themselves.
*
* Score is computed on read from existing per-student stats (time played,
* streak, activities completed, badges earned) via utils/studentStats —
* the same helpers the admin analytics dashboard uses, so the two features
* can never silently disagree about a student's numbers.
* `score` reads UserBalance.lifetimeEarned (services/ledgerService.js),
* not the spendable `balance` field — lifetimeEarned is monotonic (a sum
* of positive LedgerEntry amounts only), so it can never go down. Ranking
* by spendable balance instead would mean the day a currency store ships,
* the top of the leaderboard becomes whoever has redeemed the least —
* rewarding declining to use the system. See the currency rollout plan,
* "Rank by lifetime earned, not balance."
*
* Score weights are configurable via env vars so they can be tuned per
* environment without a deploy:
* LEADERBOARD_WEIGHT_TIME (default 1) — per hour of puzzle+lesson time
* LEADERBOARD_WEIGHT_STREAK (default 5) — per consecutive-day streak
* LEADERBOARD_WEIGHT_BADGE (default 10) — per badge earned
* LEADERBOARD_WEIGHT_ACTIVITY (default 3) — per activity completed
* This replaces the previous engagement formula computed on read from
* time/streak/activities/badges (utils/studentStats) — that formula is
* still used elsewhere (e.g. admin analytics) but is no longer this
* route's score source. A student with no UserBalance document yet (has
* never earned any currency) reads as lifetimeEarned: 0, not undefined —
* see the `|| 0` at the score assignment below; without it the sort
* comparator would produce undefined ordering instead of placing that
* student last.
*
* `score` above measures ENGAGEMENT. Student-vs-student chess results are a
* different signal (competitive skill), so they are reported alongside it as a
* separate `chess_score` / `chess_record` and are deliberately NOT added into
* `score` — blending them would make one number mean two things, and would
* compound one open weighting question into two. Chess weights live in
* utils/studentStats (PVP_WEIGHT_WIN / _DRAW / _LOSS).
* `score` above measures ENGAGEMENT (via currency earned for engaging
* actions). Student-vs-student chess results are a different signal
* (competitive skill), so they are reported alongside it as a separate
* `chess_score` / `chess_record` and are deliberately NOT added into
* `score` — blending them would make one number mean two things. Chess
* weights live in utils/studentStats (PVP_WEIGHT_WIN / _DRAW / _LOSS).
*
* Response contract matches LeaderboardModal.tsx exactly:
* GET /leaderboard/schools -> { success, schools: string[] }
Expand All @@ -39,21 +45,9 @@
const express = require("express");
const router = express.Router();
const Users = require("../models/users");
const {
getUserTimeStats,
getUserStreak,
getActivitiesCompleted,
getBadgesEarned,
getChessRecords,
} = require("../utils/studentStats");
const { getChessRecords } = require("../utils/studentStats");
const { getAvatarUrl } = require("../utils/avatars");

const WEIGHTS = {
time: parseFloat(process.env.LEADERBOARD_WEIGHT_TIME) || 1,
streak: parseFloat(process.env.LEADERBOARD_WEIGHT_STREAK) || 5,
badge: parseFloat(process.env.LEADERBOARD_WEIGHT_BADGE) || 10,
activity: parseFloat(process.env.LEADERBOARD_WEIGHT_ACTIVITY) || 3,
};
const { getLifetimeEarnedMap } = require("../services/ledgerService");

const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 10;
Expand All @@ -79,29 +73,11 @@ function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/**
* Computes the composite ENGAGEMENT score for one student from existing stat
* helpers. Placeholder weighting — confirm with product before treating as final.
*
* Chess results are intentionally absent here; they are surfaced as their own
* column (see the module header) rather than folded into this number.
*/
async function computeScore(user) {
const [timeStats, streak, activitiesCompleted, badgesEarned] = await Promise.all([
getUserTimeStats(user.username),
getUserStreak(user.username),
getActivitiesCompleted(user._id),
getBadgesEarned(user.username),
]);

const timeComponent = (timeStats.puzzleTimeHours + timeStats.lessonTimeHours) * WEIGHTS.time;
const streakComponent = streak * WEIGHTS.streak;
const badgeComponent = badgesEarned * WEIGHTS.badge;
const activityComponent = activitiesCompleted * WEIGHTS.activity;

const score = Math.round(timeComponent + streakComponent + badgeComponent + activityComponent);
return score;
}
// computeScore() (the old time/streak/badge/activity weighted formula)
// was removed here — score now comes from getLifetimeEarnedMap (see the
// module header). utils/studentStats' getUserTimeStats/getUserStreak/
// getActivitiesCompleted/getBadgesEarned still exist and are still used
// elsewhere (e.g. admin analytics); only this route's score source changed.

/**
* Builds a GET /leaderboard/<field>s handler returning distinct, non-empty
Expand Down Expand Up @@ -171,22 +147,26 @@ router.get("/", async (req, res) => {
candidates = candidates.slice(0, MAX_UNFILTERED_CANDIDATES);
}

// One batched query for every candidate's chess record, rather than a
// fifth per-student round trip inside the map below.
// One batched query for every candidate's chess record and lifetime-
// earned currency, rather than a per-student round trip inside the
// map below for either.
const chessRecords = await getChessRecords(candidates.map((u) => u.username));

const scored = await Promise.all(
candidates.map(async (user) => ({
id: String(user._id),
username: user.username,
school: user.school || null,
country: user.country || null,
state: user.state || null,
avatarUrl: getAvatarUrl(user.avatarKey),
score: await computeScore(user),
chess: chessRecords.get(user.username),
}))
);
const lifetimeEarnedMap = await getLifetimeEarnedMap(candidates.map((u) => u._id));

const scored = candidates.map((user) => ({
id: String(user._id),
username: user.username,
school: user.school || null,
country: user.country || null,
state: user.state || null,
avatarUrl: getAvatarUrl(user.avatarKey),
// A user with no UserBalance document yet (never earned any
// currency) must read as 0, not undefined — an undefined score
// would make the sort comparator below produce undefined ordering
// instead of placing that student last.
score: lifetimeEarnedMap.get(String(user._id)) || 0,
chess: chessRecords.get(user.username),
}));

const direction = sortDir === "asc" ? 1 : -1;
if (sortBy === "name") {
Expand Down
11 changes: 11 additions & 0 deletions middlewareNode/src/routes/lessons.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const { MongoClient } = require('mongodb');
require('dotenv').config();

const mongoose = require("mongoose");
const { emitLessonCompleted } = require("../services/currencyEvents");

// Cache database client to prevent repeated connections
let cachedClient = null;
Expand Down Expand Up @@ -294,6 +295,16 @@ router.get(

// check if changes have been made in db
if (updateResult.modifiedCount > 0) {
// Currency rollout: emit only on a genuine forward-progress
// write, never on the 304 branch below — a request re-sending
// an already-completed lesson number must not earn currency
// twice. eventId is deterministic per (user, piece, lessonNum),
// so even a client retry of this exact successful request can't
// double-emit; see services/currencyEvents.js. Guests (the else
// branch below) never emit — there's no account to credit.
emitLessonCompleted({ userId: req.user._id, piece, lessonNum }).catch((err) => {
console.error("currencyEvents: failed to emit lesson.completed:", err.message);
});
res.status(200).json("Lesson progress updated");
} else {
res.status(304).json("No changes made");
Expand Down
Loading
Loading