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:
@@ -1,36 +1,46 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Feature-flag middleware: blocks the request when the named feature is
|
||||
* globally disabled in app_settings.
|
||||
* Feature-flag middleware + helpers.
|
||||
*
|
||||
* Scope (B-lite): GLOBAL only — checks the app_settings.feature_<name>_enabled
|
||||
* row that admin toggles in the admin panel. Per-class disable
|
||||
* (classes.features JSON) and the free_student role-level overlay
|
||||
* (app_settings.free_student_features) are NOT checked here — those layers
|
||||
* 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.
|
||||
* requireFeature(name)
|
||||
* Express middleware that 404s when the GLOBAL feature flag is off.
|
||||
* Doesn't see per-class overrides or free_student restrictions —
|
||||
* those are layered on in computeFeaturesForUser below.
|
||||
*
|
||||
* 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:
|
||||
* 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 _stmt = db.prepare(
|
||||
"SELECT value FROM app_settings WHERE key = ?"
|
||||
const _stmtSingle = db.prepare("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) {
|
||||
const settingKey = `feature_${name}_enabled`;
|
||||
return (req, res, next) => {
|
||||
const row = _stmt.get(settingKey);
|
||||
const row = _stmtSingle.get(settingKey);
|
||||
if (row && row.value === '0') {
|
||||
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