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 = ?'),
|
||||
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
|
||||
`),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user