feat(gamification): hide locked achievements of disabled modules
When a teacher / admin turns off a module (per-class, per-role, or
globally), the matching achievements no longer clutter the user's
'Достижения' tab — but only the ones the user hasn't earned yet.
Already-unlocked achievements stay visible forever. We never take a
reward away after the fact.
Backend:
• migration 034 adds achievements.required_feature + backfills 42
rows (9 exam9, 8 red_book, 6 lab, 5 classroom, 4 textbooks, 3 each
of biochem/flashcards, 2 live_quiz, 2 pet). 32 core rows stay
NULL = always visible.
• middleware/features.js gains computeFeaturesForUser(userId, role)
+ isFeatureEnabledForUser — extracted from server.js#/api/features
so multiple consumers (gam achievements, future shop filter, etc.)
apply the same global+class+free_student merge.
• service.seedAchievements derives required_feature from track/group
when ACHIEVEMENT_DEFS doesn't spell one out, and UPDATE-syncs it on
every boot — keeps catalogue consistent across upgrades.
• _shared.getAllAchs SELECT now returns required_feature.
• gamification/api.getAchievements filters: drop locked rows whose
required_feature is === false for this user. Missing flag = ON
(opt-in disable model).
Verified: with exam9 + pet disabled, 12 locked achievements vanish from
the response while unlocked ones in those tracks remain.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -222,7 +222,7 @@ const stmts = {
|
|||||||
setUserGoalTier: db.prepare('UPDATE users SET goal_tier = ? WHERE id = ?'),
|
setUserGoalTier: db.prepare('UPDATE users SET goal_tier = ? WHERE id = ?'),
|
||||||
getAllAchs: db.prepare(`
|
getAllAchs: db.prepare(`
|
||||||
SELECT id, slug, title, icon, category, description,
|
SELECT id, slug, title, icon, category, description,
|
||||||
group_slug, track, tier, sort_order
|
group_slug, track, tier, sort_order, required_feature
|
||||||
FROM achievements
|
FROM achievements
|
||||||
ORDER BY sort_order, id
|
ORDER BY sort_order, id
|
||||||
`),
|
`),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const db = require('../../db/db');
|
const db = require('../../db/db');
|
||||||
const { stmts, GOAL_TIERS, AVATAR_FRAMES, rankName } = require('./_shared');
|
const { stmts, GOAL_TIERS, AVATAR_FRAMES, rankName } = require('./_shared');
|
||||||
|
const { computeFeaturesForUser } = require('../../middleware/features');
|
||||||
const { getXPInfo, getDailyGoal, ensureChallenges, awardXP, awardCoins } = require('./service');
|
const { getXPInfo, getDailyGoal, ensureChallenges, awardXP, awardCoins } = require('./service');
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -57,17 +58,33 @@ function setGoalTier(req, res) {
|
|||||||
Response stays an array (backward compatible). Each item now carries
|
Response stays an array (backward compatible). Each item now carries
|
||||||
`group_slug` / `track` / `tier` / `sort_order` so the UI can group +
|
`group_slug` / `track` / `tier` / `sort_order` so the UI can group +
|
||||||
order without an extra endpoint. Group display titles are hard-coded
|
order without an extra endpoint. Group display titles are hard-coded
|
||||||
client-side (small + stable). */
|
client-side (small + stable).
|
||||||
|
|
||||||
|
Feature gating: achievements whose `required_feature` is disabled for
|
||||||
|
this user (per merged global/class/free_student flags) are hidden —
|
||||||
|
but only if the user hasn't unlocked them yet. Already-earned
|
||||||
|
achievements are never taken away, even after the module is turned
|
||||||
|
off. */
|
||||||
function getAchievements(req, res) {
|
function getAchievements(req, res) {
|
||||||
const all = stmts.getAllAchs.all();
|
const all = stmts.getAllAchs.all();
|
||||||
const unlocked = stmts.getUserAchs.all(req.user.id);
|
const unlocked = stmts.getUserAchs.all(req.user.id);
|
||||||
const unlockedMap = {};
|
const unlockedMap = {};
|
||||||
for (const u of unlocked) unlockedMap[u.achievement_id] = u.unlocked_at;
|
for (const u of unlocked) unlockedMap[u.achievement_id] = u.unlocked_at;
|
||||||
const result = all.map(a => ({
|
|
||||||
|
const feats = computeFeaturesForUser(req.user.id, req.user.role);
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
for (const a of all) {
|
||||||
|
const isUnlocked = !!unlockedMap[a.id];
|
||||||
|
// Hide locked achievements whose required_feature is explicitly off.
|
||||||
|
// Missing flag is treated as ON (opt-in disable model).
|
||||||
|
if (!isUnlocked && a.required_feature && feats[a.required_feature] === false) continue;
|
||||||
|
result.push({
|
||||||
...a,
|
...a,
|
||||||
unlocked: !!unlockedMap[a.id],
|
unlocked: isUnlocked,
|
||||||
unlocked_at: unlockedMap[a.id] || null,
|
unlocked_at: unlockedMap[a.id] || null,
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
res.json(result);
|
res.json(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,12 +117,30 @@ function updateStreak(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Achievements ──────────────────────────────────────────────────── */
|
/* ── Achievements ──────────────────────────────────────────────────── */
|
||||||
|
/* Derive the required_feature flag for an achievement when its def
|
||||||
|
doesn't spell one out explicitly. Centralizes the mapping (track +
|
||||||
|
group → feature name) so future inserts in ACHIEVEMENT_DEFS inherit
|
||||||
|
the gating automatically — mirrors migration 034's backfill. */
|
||||||
|
function _requiredFeatureFor(a) {
|
||||||
|
if (a.required_feature !== undefined) return a.required_feature; // explicit wins
|
||||||
|
if (a.group === 'exam') return 'exam9';
|
||||||
|
if (a.track === 'red_book' || a.track === 'red_book_quest' || a.track === 'red_book_obs') return 'red_book';
|
||||||
|
if (a.track === 'biochem') return 'biochem';
|
||||||
|
if (a.track === 'lab' || a.track === 'lab_reactions') return 'lab';
|
||||||
|
if (a.track === 'classroom' || a.track === 'teacher') return 'classroom';
|
||||||
|
if (a.track === 'live_quiz') return 'live_quiz';
|
||||||
|
if (a.track === 'flashcards') return 'flashcards';
|
||||||
|
if (a.track === 'pet') return 'pet';
|
||||||
|
if (a.track === 'tb_progress') return 'textbooks';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function seedAchievements() {
|
function seedAchievements() {
|
||||||
// INSERT for missing rows — supplies legacy + new taxonomy fields.
|
// INSERT for missing rows — supplies legacy + new taxonomy fields.
|
||||||
const ins = db.prepare(`
|
const ins = db.prepare(`
|
||||||
INSERT OR IGNORE INTO achievements
|
INSERT OR IGNORE INTO achievements
|
||||||
(slug, title, icon, category, description, group_slug, track, tier, sort_order)
|
(slug, title, icon, category, description, group_slug, track, tier, sort_order, required_feature)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
// UPDATE for existing rows — keep title/icon/desc/category fresh AND
|
// UPDATE for existing rows — keep title/icon/desc/category fresh AND
|
||||||
// backfill the taxonomy columns added by migration 030 (so installs
|
// backfill the taxonomy columns added by migration 030 (so installs
|
||||||
@@ -136,14 +154,16 @@ function seedAchievements() {
|
|||||||
group_slug = COALESCE(?, group_slug),
|
group_slug = COALESCE(?, group_slug),
|
||||||
track = COALESCE(?, track),
|
track = COALESCE(?, track),
|
||||||
tier = COALESCE(?, tier),
|
tier = COALESCE(?, tier),
|
||||||
sort_order = ?
|
sort_order = ?,
|
||||||
|
required_feature = COALESCE(?, required_feature)
|
||||||
WHERE slug = ?
|
WHERE slug = ?
|
||||||
`);
|
`);
|
||||||
for (const a of ACHIEVEMENT_DEFS) {
|
for (const a of ACHIEVEMENT_DEFS) {
|
||||||
|
const reqFeat = _requiredFeatureFor(a);
|
||||||
ins.run(a.slug, a.title, a.icon, a.cat, a.desc,
|
ins.run(a.slug, a.title, a.icon, a.cat, a.desc,
|
||||||
a.group ?? null, a.track ?? null, a.tier ?? null, a.sort_order ?? 0);
|
a.group ?? null, a.track ?? null, a.tier ?? null, a.sort_order ?? 0, reqFeat);
|
||||||
upd.run(a.icon, a.cat, a.title, a.desc,
|
upd.run(a.icon, a.cat, a.title, a.desc,
|
||||||
a.group ?? null, a.track ?? null, a.tier ?? null, a.sort_order ?? 0,
|
a.group ?? null, a.track ?? null, a.tier ?? null, a.sort_order ?? 0, reqFeat,
|
||||||
a.slug);
|
a.slug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
-- 034: Per-achievement feature dependency
|
||||||
|
--
|
||||||
|
-- When admin (or a class teacher) turns a module off, the matching
|
||||||
|
-- achievements should disappear from the user's "Достижения" tab —
|
||||||
|
-- but ONLY when the user hasn't unlocked them yet. Already-earned
|
||||||
|
-- achievements stay visible (never take a reward away).
|
||||||
|
--
|
||||||
|
-- This migration adds `required_feature` to achievements. NULL = no
|
||||||
|
-- gating (always visible). The value matches the slug used by
|
||||||
|
-- /api/features (e.g. 'exam9', 'red_book', 'pet').
|
||||||
|
--
|
||||||
|
-- The actual filter lives in gamification/api.getAchievements: it
|
||||||
|
-- reads the user's merged features (global + class + free_student)
|
||||||
|
-- and drops every locked row whose required_feature is disabled.
|
||||||
|
-- ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
ALTER TABLE achievements ADD COLUMN required_feature TEXT;
|
||||||
|
|
||||||
|
-- ── Backfill ──────────────────────────────────────────────────
|
||||||
|
-- Group by module so future inserts inherit the pattern through
|
||||||
|
-- seedAchievements (which mirrors these defaults).
|
||||||
|
|
||||||
|
-- exam-prep (math9 + future exam tracks)
|
||||||
|
UPDATE achievements SET required_feature = 'exam9'
|
||||||
|
WHERE group_slug = 'exam';
|
||||||
|
|
||||||
|
-- red book (collection of species)
|
||||||
|
UPDATE achievements SET required_feature = 'red_book'
|
||||||
|
WHERE track LIKE 'red_book%';
|
||||||
|
|
||||||
|
-- biochem
|
||||||
|
UPDATE achievements SET required_feature = 'biochem'
|
||||||
|
WHERE track = 'biochem';
|
||||||
|
|
||||||
|
-- lab (no feature_lab_enabled flag exists today — set anyway so the
|
||||||
|
-- linkage is correct if/when one is introduced)
|
||||||
|
UPDATE achievements SET required_feature = 'lab'
|
||||||
|
WHERE track IN ('lab', 'lab_reactions');
|
||||||
|
|
||||||
|
-- classroom (joining lessons + teacher class size)
|
||||||
|
UPDATE achievements SET required_feature = 'classroom'
|
||||||
|
WHERE track IN ('classroom', 'teacher');
|
||||||
|
|
||||||
|
-- live quiz
|
||||||
|
UPDATE achievements SET required_feature = 'live_quiz'
|
||||||
|
WHERE track = 'live_quiz';
|
||||||
|
|
||||||
|
-- flashcards
|
||||||
|
UPDATE achievements SET required_feature = 'flashcards'
|
||||||
|
WHERE track = 'flashcards';
|
||||||
|
|
||||||
|
-- pet
|
||||||
|
UPDATE achievements SET required_feature = 'pet'
|
||||||
|
WHERE track = 'pet';
|
||||||
|
|
||||||
|
-- textbooks (paragraph reads, chapter completion)
|
||||||
|
UPDATE achievements SET required_feature = 'textbooks'
|
||||||
|
WHERE track = 'tb_progress';
|
||||||
|
|
||||||
|
-- Minigames (hangman/crossword) — either of two features could grant
|
||||||
|
-- progress, so 'hangman' alone is too narrow. Leave NULL to keep them
|
||||||
|
-- visible even if one game is disabled; the other can still earn them.
|
||||||
|
-- (Explicit: no update needed.)
|
||||||
@@ -1,36 +1,46 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature-flag middleware: blocks the request when the named feature is
|
* Feature-flag middleware + helpers.
|
||||||
* globally disabled in app_settings.
|
|
||||||
*
|
*
|
||||||
* Scope (B-lite): GLOBAL only — checks the app_settings.feature_<name>_enabled
|
* requireFeature(name)
|
||||||
* row that admin toggles in the admin panel. Per-class disable
|
* Express middleware that 404s when the GLOBAL feature flag is off.
|
||||||
* (classes.features JSON) and the free_student role-level overlay
|
* Doesn't see per-class overrides or free_student restrictions —
|
||||||
* (app_settings.free_student_features) are NOT checked here — those layers
|
* those are layered on in computeFeaturesForUser below.
|
||||||
* remain UI-gated in /api/features. A student bypassing the UI gate via
|
|
||||||
* direct curl is the documented limitation; can be tightened later by
|
|
||||||
* extracting the merge logic from server.js → /api/features into a shared
|
|
||||||
* helper.
|
|
||||||
*
|
*
|
||||||
* Default: missing key = enabled (opt-in disable model).
|
* computeFeaturesForUser(userId, role)
|
||||||
|
* Synchronous merge of three sources, mirroring /api/features in
|
||||||
|
* server.js (kept in one place so other endpoints — gamification
|
||||||
|
* achievements, shop, etc. — can apply the same view of "is X
|
||||||
|
* enabled for THIS user right now"):
|
||||||
|
* 1) global app_settings.feature_<name>_enabled (default = ON)
|
||||||
|
* 2) class overlay (classes.features JSON, only `false` wins)
|
||||||
|
* 3) free_student role overlay (app_settings.free_student_features)
|
||||||
|
* Returns { featureName: boolean } where boolean = is-enabled.
|
||||||
*
|
*
|
||||||
* Response: 404 on disabled feature (intentional — don't leak endpoint shape).
|
* isFeatureEnabledForUser(userId, role, featureName)
|
||||||
|
* Thin wrapper around computeFeaturesForUser for single-feature
|
||||||
|
* checks. Returns true if the feature is on (or unknown — opt-in
|
||||||
|
* disable model).
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* app.use('/api/pet', requireFeature('pet'), petRoutes);
|
* app.use('/api/pet', requireFeature('pet'), petRoutes);
|
||||||
* router.get('/hangman/word', requireFeature('hangman'), authMiddleware, handler);
|
* const feats = computeFeaturesForUser(userId, role);
|
||||||
|
* if (feats.exam9 === false) { ... }
|
||||||
*/
|
*/
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
|
|
||||||
const _stmt = db.prepare(
|
const _stmtSingle = db.prepare("SELECT value FROM app_settings WHERE key = ?");
|
||||||
"SELECT value FROM app_settings WHERE key = ?"
|
const _stmtGlobalFeats = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'");
|
||||||
|
const _stmtClassFeats = db.prepare(
|
||||||
|
'SELECT c.features FROM classes c JOIN class_members cm ON cm.class_id = c.id WHERE cm.user_id = ?'
|
||||||
);
|
);
|
||||||
|
const _stmtFreeStudentFeats = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'");
|
||||||
|
|
||||||
function requireFeature(name) {
|
function requireFeature(name) {
|
||||||
const settingKey = `feature_${name}_enabled`;
|
const settingKey = `feature_${name}_enabled`;
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const row = _stmt.get(settingKey);
|
const row = _stmtSingle.get(settingKey);
|
||||||
if (row && row.value === '0') {
|
if (row && row.value === '0') {
|
||||||
return res.status(404).json({ error: 'Feature disabled' });
|
return res.status(404).json({ error: 'Feature disabled' });
|
||||||
}
|
}
|
||||||
@@ -38,4 +48,47 @@ function requireFeature(name) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { requireFeature };
|
function computeFeaturesForUser(userId, role) {
|
||||||
|
// 1) Globals — missing = ON.
|
||||||
|
const features = {};
|
||||||
|
for (const r of _stmtGlobalFeats.all()) {
|
||||||
|
const name = r.key.replace('feature_', '').replace('_enabled', '');
|
||||||
|
features[name] = r.value === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Class overlay — only `false` wins (more restrictive).
|
||||||
|
if (userId && (role === 'student' || role === 'free_student')) {
|
||||||
|
for (const row of _stmtClassFeats.all(userId)) {
|
||||||
|
if (!row.features) continue;
|
||||||
|
try {
|
||||||
|
const f = JSON.parse(row.features);
|
||||||
|
for (const [key, val] of Object.entries(f)) {
|
||||||
|
if (val === false) features[key] = false;
|
||||||
|
}
|
||||||
|
} catch { /* malformed JSON — ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) free_student overlay.
|
||||||
|
if (role === 'free_student') {
|
||||||
|
const fsRow = _stmtFreeStudentFeats.get();
|
||||||
|
if (fsRow?.value) {
|
||||||
|
try {
|
||||||
|
const fsFeats = JSON.parse(fsRow.value);
|
||||||
|
for (const [key, val] of Object.entries(fsFeats)) {
|
||||||
|
if (val === false) features[key] = false;
|
||||||
|
}
|
||||||
|
} catch { /* malformed — ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFeatureEnabledForUser(userId, role, featureName) {
|
||||||
|
if (!featureName) return true;
|
||||||
|
const feats = computeFeaturesForUser(userId, role);
|
||||||
|
// Missing key (no row in app_settings) → treat as ON (opt-in disable).
|
||||||
|
return feats[featureName] !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireFeature, computeFeaturesForUser, isFeatureEnabledForUser };
|
||||||
|
|||||||
Reference in New Issue
Block a user