diff --git a/backend/src/controllers/gamification/_shared.js b/backend/src/controllers/gamification/_shared.js index 676e6bb..643b2ba 100644 --- a/backend/src/controllers/gamification/_shared.js +++ b/backend/src/controllers/gamification/_shared.js @@ -222,7 +222,7 @@ const stmts = { setUserGoalTier: db.prepare('UPDATE users SET goal_tier = ? WHERE id = ?'), getAllAchs: db.prepare(` SELECT id, slug, title, icon, category, description, - group_slug, track, tier, sort_order + group_slug, track, tier, sort_order, required_feature FROM achievements ORDER BY sort_order, id `), diff --git a/backend/src/controllers/gamification/api.js b/backend/src/controllers/gamification/api.js index 87aacf5..67761e6 100644 --- a/backend/src/controllers/gamification/api.js +++ b/backend/src/controllers/gamification/api.js @@ -1,6 +1,7 @@ 'use strict'; const db = require('../../db/db'); const { stmts, GOAL_TIERS, AVATAR_FRAMES, rankName } = require('./_shared'); +const { computeFeaturesForUser } = require('../../middleware/features'); 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 `group_slug` / `track` / `tier` / `sort_order` so the UI can group + 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) { const all = stmts.getAllAchs.all(); const unlocked = stmts.getUserAchs.all(req.user.id); const unlockedMap = {}; for (const u of unlocked) unlockedMap[u.achievement_id] = u.unlocked_at; - const result = all.map(a => ({ - ...a, - unlocked: !!unlockedMap[a.id], - unlocked_at: unlockedMap[a.id] || null, - })); + + 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, + unlocked: isUnlocked, + unlocked_at: unlockedMap[a.id] || null, + }); + } res.json(result); } diff --git a/backend/src/controllers/gamification/service.js b/backend/src/controllers/gamification/service.js index 11f07c4..119d87e 100644 --- a/backend/src/controllers/gamification/service.js +++ b/backend/src/controllers/gamification/service.js @@ -117,12 +117,30 @@ function updateStreak(userId) { } /* ── 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() { // INSERT for missing rows — supplies legacy + new taxonomy fields. const ins = db.prepare(` INSERT OR IGNORE INTO achievements - (slug, title, icon, category, description, group_slug, track, tier, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (slug, title, icon, category, description, group_slug, track, tier, sort_order, required_feature) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); // UPDATE for existing rows — keep title/icon/desc/category fresh AND // backfill the taxonomy columns added by migration 030 (so installs @@ -136,14 +154,16 @@ function seedAchievements() { group_slug = COALESCE(?, group_slug), track = COALESCE(?, track), tier = COALESCE(?, tier), - sort_order = ? + sort_order = ?, + required_feature = COALESCE(?, required_feature) WHERE slug = ? `); for (const a of ACHIEVEMENT_DEFS) { + const reqFeat = _requiredFeatureFor(a); 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, - 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); } } diff --git a/backend/src/db/migrations/034_achievement_required_feature.sql b/backend/src/db/migrations/034_achievement_required_feature.sql new file mode 100644 index 0000000..e82451e --- /dev/null +++ b/backend/src/db/migrations/034_achievement_required_feature.sql @@ -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.) diff --git a/backend/src/middleware/features.js b/backend/src/middleware/features.js index cc7532d..24828dd 100644 --- a/backend/src/middleware/features.js +++ b/backend/src/middleware/features.js @@ -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__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__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 };