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:
Maxim Dolgolyov
2026-05-29 20:40:16 +03:00
parent dbeee44fc7
commit 41ca41d69c
5 changed files with 183 additions and 29 deletions
@@ -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);
}
}