LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,854 @@
|
||||
const db = require('../db/db');
|
||||
const sse = require('../sse');
|
||||
const { pushParentNotif } = require('../utils/notifications');
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Gamification — XP, Levels, Streaks, Achievements, Leaderboard, Goals
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
// ── XP thresholds: level = floor(sqrt(xp / 100)) + 1 ──
|
||||
function xpToLevel(xp) { return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; }
|
||||
function levelMinXp(lv) { return (lv - 1) * (lv - 1) * 100; }
|
||||
function levelMaxXp(lv) { return lv * lv * 100; }
|
||||
|
||||
const RANKS = [
|
||||
[1, 'Новичок'],
|
||||
[5, 'Ученик'],
|
||||
[10, 'Знаток'],
|
||||
[15, 'Эксперт'],
|
||||
[20, 'Мастер'],
|
||||
[30, 'Гуру'],
|
||||
];
|
||||
function rankName(level) {
|
||||
let name = 'Новичок';
|
||||
for (const [min, r] of RANKS) if (level >= min) name = r;
|
||||
return name;
|
||||
}
|
||||
|
||||
/* ── Prepared statements (module-level to avoid re-parsing per request) ── */
|
||||
const stmts = {
|
||||
// XP & coins (hot: called on every test finish)
|
||||
insertXpLog: db.prepare('INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)'),
|
||||
incrXP: db.prepare('UPDATE users SET xp = xp + ? WHERE id = ?'),
|
||||
getXP: db.prepare('SELECT xp FROM users WHERE id = ?'),
|
||||
setLevel: db.prepare('UPDATE users SET level = ? WHERE id = ?'),
|
||||
incrCoins: db.prepare('UPDATE users SET coins = coins + ? WHERE id = ?'),
|
||||
|
||||
// Streak
|
||||
getStreak: db.prepare('SELECT streak_current, streak_best, streak_date FROM users WHERE id = ?'),
|
||||
setStreak: db.prepare('UPDATE users SET streak_current = ?, streak_best = ?, streak_date = ? WHERE id = ?'),
|
||||
|
||||
// getXPInfo
|
||||
getUserXPInfo: db.prepare('SELECT xp, level, streak_current, streak_best, streak_date FROM users WHERE id = ?'),
|
||||
|
||||
countUserAssignments: db.prepare(`SELECT COUNT(*) as n FROM assignment_sessions ass JOIN test_sessions ts ON ts.id = ass.session_id WHERE ts.user_id = ? AND ts.status = 'completed'`),
|
||||
|
||||
// Achievements (hot: up to 22 checks per test finish)
|
||||
getAchBySlug: db.prepare('SELECT id, title, icon FROM achievements WHERE slug = ?'),
|
||||
hasUserAch: db.prepare('SELECT 1 FROM user_achievements WHERE user_id = ? AND achievement_id = ?'),
|
||||
insertUserAch: db.prepare('INSERT INTO user_achievements (user_id, achievement_id) VALUES (?, ?)'),
|
||||
insertAchNotif: db.prepare("INSERT INTO notifications (user_id, type, message, link) VALUES (?, 'achievement', ?, '/profile')"),
|
||||
getUserForAch: db.prepare(`
|
||||
SELECT u.xp, u.level, u.streak_current, u.lab_experiments, u.lab_reactions,
|
||||
(SELECT COUNT(*) FROM test_sessions WHERE user_id = u.id AND status = 'completed') AS test_count,
|
||||
(SELECT COUNT(*) FROM test_sessions WHERE user_id = u.id AND status = 'completed' AND score = total) AS perfect_count,
|
||||
(SELECT COUNT(*) FROM class_members WHERE user_id = u.id) AS class_count
|
||||
FROM users u WHERE u.id = ?
|
||||
`),
|
||||
getLast5Tests: db.prepare(`
|
||||
SELECT score, total FROM test_sessions
|
||||
WHERE user_id = ? AND status = 'completed'
|
||||
ORDER BY finished_at DESC LIMIT 5
|
||||
`),
|
||||
|
||||
// Lab
|
||||
incrLabExp: db.prepare('UPDATE users SET lab_experiments = lab_experiments + 1 WHERE id = ?'),
|
||||
incrLabReact: db.prepare('UPDATE users SET lab_reactions = lab_reactions + ? WHERE id = ?'),
|
||||
|
||||
// Daily goals
|
||||
getDailyGoal: db.prepare('SELECT * FROM daily_goals WHERE user_id = ? AND date = ?'),
|
||||
getUserGoalTier: db.prepare('SELECT goal_tier FROM users WHERE id = ?'),
|
||||
insertDailyGoal: db.prepare('INSERT INTO daily_goals (user_id, date, tests_target, tests_done, xp_target, xp_earned) VALUES (?, ?, ?, 0, ?, 0)'),
|
||||
incrDailyGoal: db.prepare('UPDATE daily_goals SET tests_done = tests_done + ?, xp_earned = xp_earned + ? WHERE user_id = ? AND date = ?'),
|
||||
checkGoalBonus: db.prepare("SELECT 1 FROM xp_log WHERE user_id = ? AND reason = 'daily_goal' AND date(created_at) = ?"),
|
||||
|
||||
// Challenges
|
||||
getOpenChallenges: db.prepare('SELECT * FROM challenges WHERE user_id = ? AND week = ? AND completed = 0'),
|
||||
incrChallenge: db.prepare('UPDATE challenges SET progress = MIN(progress + ?, target) WHERE id = ?'),
|
||||
getChallengeById: db.prepare('SELECT * FROM challenges WHERE id = ?'),
|
||||
completeChallenge: db.prepare('UPDATE challenges SET completed = 1 WHERE id = ?'),
|
||||
getChallengesWeek: db.prepare('SELECT * FROM challenges WHERE user_id = ? AND week = ? ORDER BY completed, id'),
|
||||
getChallengeOwned: db.prepare('SELECT * FROM challenges WHERE id = ? AND user_id = ?'),
|
||||
markClaimed: db.prepare('UPDATE challenges SET claimed = 1 WHERE id = ?'),
|
||||
|
||||
// API handlers (dashboard / profile load)
|
||||
getUserPrefs: db.prepare('SELECT goal_tier, avatar_frame FROM users WHERE id = ?'),
|
||||
getUnlockedSlugs: db.prepare('SELECT a.slug FROM user_achievements ua JOIN achievements a ON a.id = ua.achievement_id WHERE ua.user_id = ?'),
|
||||
getUserFrame: db.prepare('SELECT avatar_frame FROM users WHERE id = ?'),
|
||||
checkFrameUnlock: db.prepare('SELECT a.id FROM achievements a JOIN user_achievements ua ON ua.achievement_id = a.id WHERE a.slug = ? AND ua.user_id = ?'),
|
||||
setUserFrame: db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?'),
|
||||
setUserGoalTier: db.prepare('UPDATE users SET goal_tier = ? WHERE id = ?'),
|
||||
getAllAchs: db.prepare('SELECT id, slug, title, icon, category, description FROM achievements ORDER BY id'),
|
||||
getUserAchs: db.prepare('SELECT achievement_id, unlocked_at FROM user_achievements WHERE user_id = ?'),
|
||||
xpHistory: db.prepare('SELECT amount, reason, created_at FROM xp_log WHERE user_id = ? ORDER BY created_at DESC LIMIT ?'),
|
||||
|
||||
// Admin
|
||||
checkUserById: db.prepare('SELECT id FROM users WHERE id = ?'),
|
||||
getUserGamInfo: db.prepare('SELECT xp, level, coins FROM users WHERE id = ?'),
|
||||
adminResetUser: db.prepare("UPDATE users SET xp=0, level=1, coins=0, streak_current=0, streak_best=0, streak_date=NULL, avatar_frame='default' WHERE id=?"),
|
||||
deleteXpLog: db.prepare('DELETE FROM xp_log WHERE user_id=?'),
|
||||
deleteUserAchs: db.prepare('DELETE FROM user_achievements WHERE user_id=?'),
|
||||
deleteDailyGoals: db.prepare('DELETE FROM daily_goals WHERE user_id=?'),
|
||||
deleteChallenges: db.prepare('DELETE FROM challenges WHERE user_id=?'),
|
||||
deleteUserPurch: db.prepare('DELETE FROM user_purchases WHERE user_id=?'),
|
||||
adminGetUserFull: db.prepare('SELECT id, name, xp, level, coins, streak_current, streak_best, goal_tier, avatar_frame FROM users WHERE id=?'),
|
||||
adminGetUserAchs: db.prepare('SELECT a.slug, a.title, a.icon, ua.unlocked_at FROM user_achievements ua JOIN achievements a ON a.id=ua.achievement_id WHERE ua.user_id=?'),
|
||||
adminGetUserPurch: db.prepare('SELECT si.name, si.type, up.purchased_at FROM user_purchases up JOIN shop_items si ON si.id=up.item_id WHERE up.user_id=?'),
|
||||
adminGetUserXPH: db.prepare('SELECT amount, reason, created_at FROM xp_log WHERE user_id=? ORDER BY created_at DESC LIMIT 30'),
|
||||
adminTotalXP: db.prepare('SELECT COALESCE(SUM(xp),0) as v FROM users'),
|
||||
adminTotalCoins: db.prepare('SELECT COALESCE(SUM(coins),0) as v FROM users'),
|
||||
adminAvgLevel: db.prepare("SELECT ROUND(AVG(level),1) as v FROM users WHERE role='student'"),
|
||||
adminAchCount: db.prepare('SELECT COUNT(*) as v FROM user_achievements'),
|
||||
adminTopXP: db.prepare("SELECT id, name, xp, level, coins FROM users WHERE role='student' ORDER BY xp DESC LIMIT 10"),
|
||||
adminRecentXP: db.prepare('SELECT xl.amount, xl.reason, xl.created_at, u.name FROM xp_log xl JOIN users u ON u.id=xl.user_id ORDER BY xl.created_at DESC LIMIT 20'),
|
||||
adminTotalPurch: db.prepare('SELECT COUNT(*) as v FROM user_purchases'),
|
||||
adminRecentPurch: db.prepare(`SELECT up.purchased_at, u.name AS user_name, si.name AS item_name, si.price, si.type
|
||||
FROM user_purchases up JOIN users u ON u.id=up.user_id JOIN shop_items si ON si.id=up.item_id
|
||||
ORDER BY up.purchased_at DESC LIMIT 20`),
|
||||
};
|
||||
|
||||
/* ── Coins service ────────────────────────────────────────────────────── */
|
||||
|
||||
function awardCoins(userId, amount, reason) {
|
||||
if (!amount || amount <= 0) return;
|
||||
stmts.incrCoins.run(amount, userId);
|
||||
}
|
||||
|
||||
/* ── XP service ───────────────────────────────────────────────────────── */
|
||||
|
||||
function awardXP(userId, amount, reason) {
|
||||
if (!amount || amount <= 0) return;
|
||||
stmts.insertXpLog.run(userId, amount, reason);
|
||||
stmts.incrXP.run(amount, userId);
|
||||
const user = stmts.getXP.get(userId);
|
||||
if (user) {
|
||||
const newLevel = xpToLevel(user.xp);
|
||||
stmts.setLevel.run(newLevel, userId);
|
||||
}
|
||||
// Award coins proportionally: 1 coin per 10 XP
|
||||
awardCoins(userId, Math.floor(amount / 10), reason);
|
||||
}
|
||||
|
||||
function getXPInfo(userId) {
|
||||
const user = stmts.getUserXPInfo.get(userId);
|
||||
if (!user) return null;
|
||||
// Always derive level from XP so stale DB level never causes wrong display
|
||||
const level = xpToLevel(user.xp);
|
||||
// Keep DB in sync (silently)
|
||||
if (user.level !== level) stmts.setLevel.run(level, userId);
|
||||
return {
|
||||
xp: user.xp || 0,
|
||||
level,
|
||||
rank: rankName(level),
|
||||
levelMin: levelMinXp(level),
|
||||
levelMax: levelMaxXp(level),
|
||||
streak: user.streak_current || 0,
|
||||
streakBest: user.streak_best || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Streak service ───────────────────────────────────────────────────── */
|
||||
|
||||
function updateStreak(userId) {
|
||||
const user = stmts.getStreak.get(userId);
|
||||
if (!user) return;
|
||||
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
if (user.streak_date === today) return; // already counted today
|
||||
|
||||
const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
const oldStreak = user.streak_current || 0;
|
||||
let newStreak;
|
||||
if (user.streak_date === yesterday) {
|
||||
newStreak = oldStreak + 1;
|
||||
} else {
|
||||
newStreak = 1;
|
||||
// Notify parents about streak loss (only if was meaningful)
|
||||
if (oldStreak >= 3) {
|
||||
const u = db.prepare('SELECT name FROM users WHERE id = ?').get(userId);
|
||||
pushParentNotif(userId, 'streak_lost', `${u?.name || 'Ученик'} потерял стрик (было ${oldStreak} дней)`);
|
||||
}
|
||||
}
|
||||
const newBest = Math.max(newStreak, user.streak_best || 0);
|
||||
stmts.setStreak.run(newStreak, newBest, today, userId);
|
||||
|
||||
// streak XP bonus (first activity of the day)
|
||||
awardXP(userId, 30, 'daily_activity');
|
||||
|
||||
return newStreak;
|
||||
}
|
||||
|
||||
/* ── Achievement definitions ──────────────────────────────────────────── */
|
||||
|
||||
const ACHIEVEMENT_DEFS = [
|
||||
// First steps
|
||||
{ slug: 'first_test', title: 'Первый тест', icon: 'target', cat: 'start', desc: 'Пройти свой первый тест' },
|
||||
{ slug: 'first_perfect', title: 'Идеальный результат', icon: 'hundred', cat: 'start', desc: 'Получить 100% на тесте' },
|
||||
{ slug: 'first_class', title: 'Вступил в класс', icon: 'school', cat: 'start', desc: 'Присоединиться к классу' },
|
||||
// Streaks
|
||||
{ slug: 'streak_3', title: 'Три дня подряд', icon: 'flame', cat: 'streak', desc: '3 дня активности подряд' },
|
||||
{ slug: 'streak_7', title: 'Неделя подряд', icon: 'flame', cat: 'streak', desc: '7 дней активности подряд' },
|
||||
{ slug: 'streak_30', title: 'Месяц подряд', icon: 'flame', cat: 'streak', desc: '30 дней активности подряд' },
|
||||
// Volume
|
||||
{ slug: 'tests_10', title: '10 тестов', icon: 'file-text', cat: 'volume', desc: 'Завершить 10 тестов' },
|
||||
{ slug: 'tests_50', title: '50 тестов', icon: 'books', cat: 'volume', desc: 'Завершить 50 тестов' },
|
||||
{ slug: 'tests_100', title: '100 тестов', icon: 'trophy', cat: 'volume', desc: 'Завершить 100 тестов' },
|
||||
// Mastery
|
||||
{ slug: 'score_90', title: 'Отличник', icon: 'star', cat: 'mastery', desc: '5 тестов подряд на 90%+' },
|
||||
{ slug: 'speed_demon', title: 'Скорострел', icon: 'zap', cat: 'mastery', desc: 'Тест на 90%+ за <50% времени' },
|
||||
// Levels
|
||||
{ slug: 'level_5', title: 'Ученик', icon: 'book-open', cat: 'level', desc: 'Достичь 5 уровня' },
|
||||
{ slug: 'level_10', title: 'Знаток', icon: 'brain', cat: 'level', desc: 'Достичь 10 уровня' },
|
||||
{ slug: 'level_20', title: 'Мастер', icon: 'crown', cat: 'level', desc: 'Достичь 20 уровня' },
|
||||
// XP milestones
|
||||
{ slug: 'xp_1000', title: '1000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 1000 XP' },
|
||||
{ slug: 'xp_5000', title: '5000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 5000 XP' },
|
||||
{ slug: 'xp_10000', title: '10 000 XP', icon: 'diamond', cat: 'xp', desc: 'Набрать 10 000 XP' },
|
||||
// Lab / experiments
|
||||
{ slug: 'lab_first', title: 'Первый опыт', icon: 'flask-conical', cat: 'lab', desc: 'Провести первый эксперимент в лаборатории' },
|
||||
{ slug: 'lab_5', title: 'Юный химик', icon: 'flask-conical', cat: 'lab', desc: 'Провести 5 экспериментов' },
|
||||
{ slug: 'lab_20', title: 'Лаборант', icon: 'test-tubes', cat: 'lab', desc: 'Провести 20 экспериментов' },
|
||||
{ slug: 'lab_50', title: 'Исследователь', icon: 'microscope', cat: 'lab', desc: 'Провести 50 экспериментов' },
|
||||
{ slug: 'lab_reactions_10',title: '10 реакций', icon: 'atom', cat: 'lab', desc: 'Обнаружить 10 различных реакций' },
|
||||
{ slug: 'lab_reactions_30',title: '30 реакций', icon: 'atom', cat: 'lab', desc: 'Обнаружить 30 различных реакций' },
|
||||
// Extra level milestones
|
||||
{ slug: 'level_3', title: 'Начинающий', icon: 'book-open', cat: 'level', desc: 'Достичь 3 уровня' },
|
||||
// Red Book
|
||||
{ slug: 'rb_first', title: 'Первый вид КК', icon: 'leaf', cat: 'redbook', desc: 'Открыть первый вид Красной книги' },
|
||||
{ slug: 'rb_10', title: '10 видов КК', icon: 'leaf', cat: 'redbook', desc: 'Открыть 10 видов Красной книги' },
|
||||
{ slug: 'rb_25', title: 'Четверть коллекции', icon: 'trees', cat: 'redbook', desc: 'Открыть 25 видов Красной книги' },
|
||||
{ slug: 'rb_50', title: 'Половина коллекции', icon: 'trees', cat: 'redbook', desc: 'Открыть 50 видов Красной книги' },
|
||||
{ slug: 'rb_all_cr', title: 'Защитник природы', icon: 'shield-check', cat: 'redbook', desc: 'Открыть все CR-виды Красной книги' },
|
||||
{ slug: 'rb_quest_first', title: 'Первый квест КК', icon: 'map', cat: 'redbook', desc: 'Выполнить первый квест Красной книги' },
|
||||
{ slug: 'rb_quest_5', title: 'Квестмастер КК', icon: 'map-pin', cat: 'redbook', desc: 'Выполнить 5 квестов Красной книги' },
|
||||
{ slug: 'rb_sighting', title: 'Наблюдатель', icon: 'eye', cat: 'redbook', desc: 'Добавить первое наблюдение вида' },
|
||||
// Theory / Library
|
||||
{ slug: 'theory_first', title: 'Первый урок', icon: 'book-open', cat: 'theory', desc: 'Прочитать первый урок' },
|
||||
{ slug: 'theory_10', title: 'Читатель', icon: 'library', cat: 'theory', desc: 'Прочитать 10 уроков' },
|
||||
{ slug: 'theory_course', title: 'Завершил курс', icon: 'graduation-cap',cat: 'theory', desc: 'Пройти полный курс целиком' },
|
||||
// Assignments
|
||||
{ slug: 'assign_first', title: 'Первое задание', icon: 'clipboard', cat: 'assign', desc: 'Сдать первое задание' },
|
||||
{ slug: 'assign_10', title: '10 заданий', icon: 'clipboard-check',cat: 'assign', desc: 'Сдать 10 заданий' },
|
||||
];
|
||||
|
||||
// Avatar frames unlocked by achievements
|
||||
const AVATAR_FRAMES = [
|
||||
{ id: 'default', name: 'Стандарт', css: '', unlock: null },
|
||||
{ id: 'fire', name: 'Огненная', css: 'box-shadow:0 0 0 3px #FF6B35,0 0 12px rgba(255,107,53,0.4)', unlock: 'streak_7' },
|
||||
{ id: 'diamond', name: 'Бриллиант', css: 'box-shadow:0 0 0 3px #06D6E0,0 0 12px rgba(6,214,224,0.4)', unlock: 'xp_5000' },
|
||||
{ id: 'gold', name: 'Золотая', css: 'box-shadow:0 0 0 3px #FFD700,0 0 12px rgba(255,215,0,0.4)', unlock: 'tests_100' },
|
||||
{ id: 'violet_glow', name: 'Фиолет', css: 'box-shadow:0 0 0 3px #9B5DE5,0 0 16px rgba(155,93,229,0.5)', unlock: 'level_10' },
|
||||
{ id: 'rainbow', name: 'Радуга', css: 'background:conic-gradient(#FF6B6B,#FFD93D,#6BCB77,#4D96FF,#9B5DE5,#FF6B6B);padding:3px', unlock: 'level_20' },
|
||||
{ id: 'crown', name: 'Корона', css: 'box-shadow:0 0 0 3px #FFD700,0 0 20px rgba(255,215,0,0.6)', unlock: 'xp_10000' },
|
||||
{ id: 'perfect', name: 'Идеал', css: 'box-shadow:0 0 0 3px #06D664,0 0 12px rgba(6,214,100,0.4)', unlock: 'first_perfect' },
|
||||
];
|
||||
|
||||
function seedAchievements() {
|
||||
const ins = db.prepare(`
|
||||
INSERT OR IGNORE INTO achievements (slug, title, icon, category, description)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
const upd = db.prepare(`
|
||||
UPDATE achievements SET icon = ?, category = ?, title = ?, description = ?
|
||||
WHERE slug = ? AND (icon IS NULL OR icon = '' OR icon != ?)
|
||||
`);
|
||||
for (const a of ACHIEVEMENT_DEFS) {
|
||||
ins.run(a.slug, a.title, a.icon, a.cat, a.desc);
|
||||
upd.run(a.icon, a.cat, a.title, a.desc, a.slug, a.icon);
|
||||
}
|
||||
}
|
||||
|
||||
function unlockAchievement(userId, slug) {
|
||||
const ach = stmts.getAchBySlug.get(slug);
|
||||
if (!ach) return false;
|
||||
const exists = stmts.hasUserAch.get(userId, ach.id);
|
||||
if (exists) return false;
|
||||
stmts.insertUserAch.run(userId, ach.id);
|
||||
// Award bonus XP
|
||||
awardXP(userId, 50, 'achievement:' + slug);
|
||||
// Notify via SSE
|
||||
pushAchievementNotif(userId, ach);
|
||||
return true;
|
||||
}
|
||||
|
||||
function pushAchievementNotif(userId, ach) {
|
||||
try {
|
||||
stmts.insertAchNotif.run(userId, `Достижение: ${ach.title}`);
|
||||
sse.emit(userId, { type: 'achievement', message: `Достижение: ${ach.title}`, icon: ach.icon, title: ach.title });
|
||||
// Award 50 coins per achievement
|
||||
awardCoins(userId, 50, 'achievement:' + (ach.slug || ach.title));
|
||||
// Notify parents
|
||||
const u = db.prepare('SELECT name FROM users WHERE id = ?').get(userId);
|
||||
pushParentNotif(userId, 'achievement', `${u?.name || 'Ученик'} получил достижение: ${ach.title}`);
|
||||
} catch (e) { console.error('[achievement]', e.message); }
|
||||
}
|
||||
|
||||
/* ── Achievement check engine ─────────────────────────────────────────── */
|
||||
|
||||
function checkAchievements(userId) {
|
||||
// Single query: user fields + session counts in one round-trip
|
||||
const row = stmts.getUserForAch.get(userId);
|
||||
if (!row) return;
|
||||
|
||||
const { test_count: testCount, perfect_count: perfectCount, class_count: classCount } = row;
|
||||
|
||||
// Tests
|
||||
if (testCount >= 1) unlockAchievement(userId, 'first_test');
|
||||
if (testCount >= 10) unlockAchievement(userId, 'tests_10');
|
||||
if (testCount >= 50) unlockAchievement(userId, 'tests_50');
|
||||
if (testCount >= 100) unlockAchievement(userId, 'tests_100');
|
||||
|
||||
// Perfect score
|
||||
if (perfectCount >= 1) unlockAchievement(userId, 'first_perfect');
|
||||
|
||||
// Streaks
|
||||
const streak = row.streak_current || 0;
|
||||
if (streak >= 3) unlockAchievement(userId, 'streak_3');
|
||||
if (streak >= 7) unlockAchievement(userId, 'streak_7');
|
||||
if (streak >= 30) unlockAchievement(userId, 'streak_30');
|
||||
|
||||
// Level (always derive from XP)
|
||||
const level = xpToLevel(row.xp || 0);
|
||||
if (level >= 3) unlockAchievement(userId, 'level_3');
|
||||
if (level >= 5) unlockAchievement(userId, 'level_5');
|
||||
if (level >= 10) unlockAchievement(userId, 'level_10');
|
||||
if (level >= 20) unlockAchievement(userId, 'level_20');
|
||||
|
||||
// XP
|
||||
const xp = row.xp || 0;
|
||||
if (xp >= 1000) unlockAchievement(userId, 'xp_1000');
|
||||
if (xp >= 5000) unlockAchievement(userId, 'xp_5000');
|
||||
if (xp >= 10000) unlockAchievement(userId, 'xp_10000');
|
||||
|
||||
// Class membership
|
||||
if (classCount >= 1) unlockAchievement(userId, 'first_class');
|
||||
|
||||
// 5 tests in a row with ≥90%
|
||||
const last5 = stmts.getLast5Tests.all(userId);
|
||||
if (last5.length >= 5 && last5.every(r => r.total > 0 && (r.score / r.total) >= 0.9)) {
|
||||
unlockAchievement(userId, 'score_90');
|
||||
}
|
||||
|
||||
// Lab
|
||||
const labExp = row.lab_experiments || 0;
|
||||
const labReact = row.lab_reactions || 0;
|
||||
if (labExp >= 1) unlockAchievement(userId, 'lab_first');
|
||||
if (labExp >= 5) unlockAchievement(userId, 'lab_5');
|
||||
if (labExp >= 20) unlockAchievement(userId, 'lab_20');
|
||||
if (labExp >= 50) unlockAchievement(userId, 'lab_50');
|
||||
if (labReact >= 10) unlockAchievement(userId, 'lab_reactions_10');
|
||||
if (labReact >= 30) unlockAchievement(userId, 'lab_reactions_30');
|
||||
|
||||
// Assignments (via assignment_sessions)
|
||||
try {
|
||||
const ac = stmts.countUserAssignments.get(userId);
|
||||
const assignCount = ac?.n || 0;
|
||||
if (assignCount >= 1) unlockAchievement(userId, 'assign_first');
|
||||
if (assignCount >= 10) unlockAchievement(userId, 'assign_10');
|
||||
} catch (e) { console.error('[achievements] assignment check:', e.message); }
|
||||
}
|
||||
|
||||
/* ── Hook: Red Book species collected / sighting added ─────────────────── */
|
||||
|
||||
function checkRedBookAchievements(userId) {
|
||||
try {
|
||||
const collected = db.prepare('SELECT COUNT(*) as n FROM rb_user_collection WHERE user_id = ?').get(userId)?.n || 0;
|
||||
if (collected >= 1) unlockAchievement(userId, 'rb_first');
|
||||
if (collected >= 10) unlockAchievement(userId, 'rb_10');
|
||||
if (collected >= 25) unlockAchievement(userId, 'rb_25');
|
||||
if (collected >= 50) unlockAchievement(userId, 'rb_50');
|
||||
|
||||
const crTotal = db.prepare("SELECT COUNT(*) as n FROM rb_species WHERE category = 'CR'").get().n;
|
||||
const crCollected = db.prepare(`
|
||||
SELECT COUNT(*) as n FROM rb_user_collection uc
|
||||
JOIN rb_species s ON s.id = uc.species_id
|
||||
WHERE uc.user_id = ? AND s.category = 'CR'
|
||||
`).get(userId)?.n || 0;
|
||||
if (crTotal > 0 && crCollected >= crTotal) unlockAchievement(userId, 'rb_all_cr');
|
||||
|
||||
const quests = db.prepare("SELECT COUNT(*) as n FROM rb_user_quests WHERE user_id = ? AND status = 'completed'").get(userId)?.n || 0;
|
||||
if (quests >= 1) unlockAchievement(userId, 'rb_quest_first');
|
||||
if (quests >= 5) unlockAchievement(userId, 'rb_quest_5');
|
||||
|
||||
const sightings = db.prepare('SELECT COUNT(*) as n FROM rb_sightings WHERE user_id = ?').get(userId)?.n || 0;
|
||||
if (sightings >= 1) unlockAchievement(userId, 'rb_sighting');
|
||||
|
||||
checkAchievements(userId); // also check level/xp milestones
|
||||
} catch (e) { console.error('[checkRedBookAchievements]', e.message); }
|
||||
}
|
||||
|
||||
/* ── Hook: called after lesson marked complete ──────────────────────────── */
|
||||
|
||||
function onLessonComplete(userId, courseId) {
|
||||
try {
|
||||
awardXP(userId, 30, 'lesson_complete');
|
||||
const done = db.prepare('SELECT COUNT(*) as n FROM lesson_progress WHERE user_id = ? AND completed = 1').get(userId)?.n || 0;
|
||||
if (done >= 1) unlockAchievement(userId, 'theory_first');
|
||||
if (done >= 10) unlockAchievement(userId, 'theory_10');
|
||||
if (courseId) {
|
||||
const total = db.prepare('SELECT COUNT(*) as n FROM lessons WHERE course_id = ? AND is_published = 1').get(courseId)?.n || 0;
|
||||
const courseDone = db.prepare(`
|
||||
SELECT COUNT(*) as n FROM lesson_progress lp
|
||||
JOIN lessons l ON lp.lesson_id = l.id
|
||||
WHERE l.course_id = ? AND lp.user_id = ? AND lp.completed = 1
|
||||
`).get(courseId, userId)?.n || 0;
|
||||
if (total > 0 && courseDone >= total) unlockAchievement(userId, 'theory_course');
|
||||
}
|
||||
checkAchievements(userId);
|
||||
} catch (e) { console.error('[onLessonComplete]', e.message); }
|
||||
}
|
||||
|
||||
/* ── Hook: called after test finishes ─────────────────────────────────── */
|
||||
|
||||
function onTestFinished(userId, score, total, timeSec, testTimeLimitSec) {
|
||||
const pct = total > 0 ? score / total : 0;
|
||||
|
||||
// XP for correct answers
|
||||
awardXP(userId, score * 10, 'correct_answers');
|
||||
|
||||
// XP for completing test
|
||||
awardXP(userId, 50, 'test_complete');
|
||||
|
||||
// Bonus for ≥90%
|
||||
if (pct >= 0.9) awardXP(userId, 100, 'test_90+');
|
||||
|
||||
// Bonus for 100%
|
||||
if (pct >= 1.0) awardXP(userId, 200, 'test_perfect');
|
||||
|
||||
// Bonus for assignment on time (handled in sessionController after checking deadline)
|
||||
|
||||
// Ecstatic mood bonus: +10% of base XP when pet streak >= 7
|
||||
try {
|
||||
const streakRow = stmts.getStreak.get(userId);
|
||||
if (streakRow && (streakRow.streak_current || 0) >= 7) {
|
||||
const moodBonus = Math.round(score * 10 * 0.10);
|
||||
if (moodBonus > 0) awardXP(userId, moodBonus, 'mood_ecstatic');
|
||||
}
|
||||
} catch (e) { console.error('[onTestFinished] mood bonus:', e.message); }
|
||||
|
||||
// Speed demon check
|
||||
if (testTimeLimitSec && timeSec < testTimeLimitSec * 0.5 && pct >= 0.9) {
|
||||
unlockAchievement(userId, 'speed_demon');
|
||||
}
|
||||
|
||||
// Update streak
|
||||
updateStreak(userId);
|
||||
|
||||
// Check all achievements
|
||||
checkAchievements(userId);
|
||||
}
|
||||
|
||||
/* ── Hook: called when student joins class ────────────────────────────── */
|
||||
|
||||
function onClassJoined(userId) {
|
||||
checkAchievements(userId);
|
||||
}
|
||||
|
||||
/* ── Hook: called when student performs a lab experiment ───────────────── */
|
||||
|
||||
function onLabExperiment(userId, reactionsDiscovered) {
|
||||
// reactionsDiscovered — number of unique reactions found in this session
|
||||
stmts.incrLabExp.run(userId);
|
||||
if (reactionsDiscovered > 0) {
|
||||
stmts.incrLabReact.run(reactionsDiscovered, userId);
|
||||
}
|
||||
awardXP(userId, 15, 'lab_experiment');
|
||||
checkAchievements(userId);
|
||||
}
|
||||
|
||||
/* ── Daily goals ──────────────────────────────────────────────────────── */
|
||||
|
||||
const GOAL_TIERS = {
|
||||
easy: { tests: 2, xp: 100, bonus: 30, label: 'Лёгкая' },
|
||||
medium: { tests: 3, xp: 200, bonus: 50, label: 'Средняя' },
|
||||
hard: { tests: 5, xp: 500, bonus: 100, label: 'Тяжёлая' },
|
||||
};
|
||||
|
||||
function getDailyGoal(userId) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let goal = stmts.getDailyGoal.get(userId, today);
|
||||
if (!goal) {
|
||||
// Check user's preferred tier
|
||||
const pref = stmts.getUserGoalTier.get(userId);
|
||||
const tier = GOAL_TIERS[(pref && pref.goal_tier) || 'medium'] || GOAL_TIERS.medium;
|
||||
stmts.insertDailyGoal.run(userId, today, tier.tests, tier.xp);
|
||||
goal = stmts.getDailyGoal.get(userId, today);
|
||||
}
|
||||
return goal;
|
||||
}
|
||||
|
||||
function updateDailyGoal(userId, addTests, addXp) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
getDailyGoal(userId); // ensure exists
|
||||
stmts.incrDailyGoal.run(addTests || 0, addXp || 0, userId, today);
|
||||
|
||||
// Check if goal completed
|
||||
const goal = stmts.getDailyGoal.get(userId, today);
|
||||
if (goal && goal.tests_done >= goal.tests_target && goal.xp_earned >= goal.xp_target) {
|
||||
// Check if already awarded bonus today
|
||||
const already = stmts.checkGoalBonus.get(userId, today);
|
||||
if (!already) {
|
||||
const pref = stmts.getUserGoalTier.get(userId);
|
||||
const tier = GOAL_TIERS[(pref && pref.goal_tier) || 'medium'] || GOAL_TIERS.medium;
|
||||
awardXP(userId, tier.bonus, 'daily_goal');
|
||||
try {
|
||||
sse.emit(userId, { type: 'daily_goal', message: `Дневная цель выполнена! +${tier.bonus} XP`, icon: 'target' });
|
||||
} catch (e) { console.error('[daily_goal]', e.message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Personal Challenges ──────────────────────────────────────────────── */
|
||||
|
||||
function _currentWeek() {
|
||||
const d = new Date();
|
||||
const day = d.getDay();
|
||||
const mon = new Date(d);
|
||||
mon.setDate(mon.getDate() - ((day + 6) % 7));
|
||||
return mon.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function ensureChallenges(userId) {
|
||||
const week = _currentWeek();
|
||||
const existing = db.prepare('SELECT COUNT(*) AS cnt FROM challenges WHERE user_id = ? AND week = ?').get(userId, week);
|
||||
if (existing.cnt > 0) return;
|
||||
|
||||
// Auto-generate 3 challenges based on weak topics + general goals
|
||||
const weakTopics = db.prepare(`
|
||||
SELECT t.id AS topic_id, t.name, s.slug AS subject_slug, s.name AS subject_name,
|
||||
COUNT(CASE WHEN ua.is_correct = 0 THEN 1 END) AS wrong,
|
||||
COUNT(*) AS total
|
||||
FROM user_answers ua
|
||||
JOIN session_questions sq ON sq.session_id = ua.session_id AND sq.question_id = ua.question_id
|
||||
JOIN questions q ON q.id = ua.question_id
|
||||
JOIN topics t ON t.id = q.topic_id
|
||||
JOIN subjects s ON s.id = t.subject_id
|
||||
JOIN test_sessions ts ON ts.id = ua.session_id AND ts.user_id = ?
|
||||
GROUP BY t.id
|
||||
HAVING wrong > 0
|
||||
ORDER BY CAST(wrong AS REAL) / total DESC
|
||||
LIMIT 5
|
||||
`).all(userId);
|
||||
|
||||
const challenges = [];
|
||||
|
||||
// Challenge 1: Weak topic practice (if available)
|
||||
if (weakTopics.length > 0) {
|
||||
const wt = weakTopics[0];
|
||||
challenges.push({
|
||||
title: `Подтяни «${wt.name}»`,
|
||||
description: `Пройди 3 теста по теме «${wt.name}» (${wt.subject_name})`,
|
||||
type: 'topic_tests',
|
||||
target: 3,
|
||||
xp_reward: 150,
|
||||
subject_slug: wt.subject_slug,
|
||||
topic_id: wt.topic_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Challenge 2: Score challenge
|
||||
challenges.push({
|
||||
title: 'Набери 80%+',
|
||||
description: 'Заверши 3 теста с результатом не ниже 80%',
|
||||
type: 'high_score',
|
||||
target: 3,
|
||||
xp_reward: 120,
|
||||
subject_slug: null,
|
||||
topic_id: null,
|
||||
});
|
||||
|
||||
// Challenge 3: Volume challenge
|
||||
challenges.push({
|
||||
title: 'Марафонец',
|
||||
description: 'Пройди 5 тестов на этой неделе',
|
||||
type: 'tests',
|
||||
target: 5,
|
||||
xp_reward: 100,
|
||||
subject_slug: null,
|
||||
topic_id: null,
|
||||
});
|
||||
|
||||
// Challenge 4: Streak challenge (if no weak topics)
|
||||
if (weakTopics.length < 2) {
|
||||
challenges.push({
|
||||
title: 'Без ошибок',
|
||||
description: 'Набери 100% в любом тесте',
|
||||
type: 'perfect',
|
||||
target: 1,
|
||||
xp_reward: 200,
|
||||
subject_slug: null,
|
||||
topic_id: null,
|
||||
});
|
||||
}
|
||||
|
||||
const ins = db.prepare(`
|
||||
INSERT OR IGNORE INTO challenges (user_id, week, title, description, type, target, xp_reward, subject_slug, topic_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const c of challenges) {
|
||||
ins.run(userId, week, c.title, c.description, c.type, c.target, c.xp_reward, c.subject_slug, c.topic_id);
|
||||
}
|
||||
}
|
||||
|
||||
function updateChallenges(userId, score, total, subjectSlug, topicId) {
|
||||
const week = _currentWeek();
|
||||
const pct = total > 0 ? Math.round(score / total * 100) : 0;
|
||||
const challenges = stmts.getOpenChallenges.all(userId, week);
|
||||
|
||||
for (const c of challenges) {
|
||||
let inc = 0;
|
||||
switch (c.type) {
|
||||
case 'tests':
|
||||
inc = 1;
|
||||
break;
|
||||
case 'topic_tests':
|
||||
if (topicId && c.topic_id === topicId) inc = 1;
|
||||
else if (subjectSlug && c.subject_slug === subjectSlug) inc = 1;
|
||||
break;
|
||||
case 'high_score':
|
||||
if (pct >= 80) inc = 1;
|
||||
break;
|
||||
case 'perfect':
|
||||
if (pct >= 100) inc = 1;
|
||||
break;
|
||||
}
|
||||
if (inc > 0) {
|
||||
stmts.incrChallenge.run(inc, c.id);
|
||||
const updated = stmts.getChallengeById.get(c.id);
|
||||
if (updated && updated.progress >= updated.target) {
|
||||
stmts.completeChallenge.run(c.id);
|
||||
try {
|
||||
sse.emit(userId, { type: 'challenge', message: `Испытание «${c.title}» выполнено!`, icon: 'target' });
|
||||
} catch (e) { console.error('[challenge]', e.message); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getChallenges(req, res) {
|
||||
ensureChallenges(req.user.id);
|
||||
const week = _currentWeek();
|
||||
const rows = stmts.getChallengesWeek.all(req.user.id, week);
|
||||
res.json(rows);
|
||||
}
|
||||
|
||||
function claimChallenge(req, res) {
|
||||
const id = Number(req.params.id);
|
||||
const c = stmts.getChallengeOwned.get(id, req.user.id);
|
||||
if (!c) return res.status(404).json({ error: 'Challenge not found' });
|
||||
if (!c.completed) return res.status(400).json({ error: 'Challenge not completed yet' });
|
||||
if (c.claimed) return res.status(400).json({ error: 'Already claimed' });
|
||||
|
||||
stmts.markClaimed.run(id);
|
||||
awardXP(req.user.id, c.xp_reward, `Испытание: ${c.title}`);
|
||||
// Bonus coins for challenges
|
||||
awardCoins(req.user.id, Math.floor(c.xp_reward / 5), `Испытание: ${c.title}`);
|
||||
res.json({ xp: c.xp_reward });
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
API Handlers
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* GET /api/gamification/me — current user XP, level, streak, goals */
|
||||
function getMe(req, res) {
|
||||
const info = getXPInfo(req.user.id);
|
||||
if (!info) return res.status(404).json({ error: 'User not found' });
|
||||
const goal = getDailyGoal(req.user.id);
|
||||
const pref = stmts.getUserPrefs.get(req.user.id);
|
||||
const tierKey = (pref && pref.goal_tier) || 'medium';
|
||||
const frameId = (pref && pref.avatar_frame) || 'default';
|
||||
const frame = AVATAR_FRAMES.find(f => f.id === frameId) || AVATAR_FRAMES[0];
|
||||
res.json({ ...info, dailyGoal: goal, goalTier: tierKey, goalTiers: GOAL_TIERS, avatarFrame: frame });
|
||||
}
|
||||
|
||||
/* GET /api/gamification/frames — available avatar frames */
|
||||
function getFrames(req, res) {
|
||||
const unlocked = stmts.getUnlockedSlugs.all(req.user.id).map(r => r.slug);
|
||||
const user = stmts.getUserFrame.get(req.user.id);
|
||||
const selected = (user && user.avatar_frame) || 'default';
|
||||
const frames = AVATAR_FRAMES.map(f => ({
|
||||
...f,
|
||||
unlocked: !f.unlock || unlocked.includes(f.unlock),
|
||||
selected: f.id === selected,
|
||||
}));
|
||||
res.json({ frames, selected });
|
||||
}
|
||||
|
||||
/* POST /api/gamification/frame — set avatar frame */
|
||||
function setFrame(req, res) {
|
||||
const { frame } = req.body;
|
||||
const f = AVATAR_FRAMES.find(fr => fr.id === frame);
|
||||
if (!f) return res.status(400).json({ error: 'Unknown frame' });
|
||||
if (f.unlock) {
|
||||
const ach = stmts.checkFrameUnlock.get(f.unlock, req.user.id);
|
||||
if (!ach) return res.status(403).json({ error: 'Frame not unlocked' });
|
||||
}
|
||||
stmts.setUserFrame.run(frame, req.user.id);
|
||||
res.json({ frame, css: f.css });
|
||||
}
|
||||
|
||||
/* POST /api/gamification/goal-tier — set daily goal difficulty */
|
||||
function setGoalTier(req, res) {
|
||||
const { tier } = req.body;
|
||||
if (!GOAL_TIERS[tier]) return res.status(400).json({ error: 'Invalid tier. Use: easy, medium, hard' });
|
||||
stmts.setUserGoalTier.run(tier, req.user.id);
|
||||
res.json({ tier, ...GOAL_TIERS[tier] });
|
||||
}
|
||||
|
||||
/* GET /api/gamification/achievements — all achievements + user unlocks */
|
||||
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 }));
|
||||
res.json(result);
|
||||
}
|
||||
|
||||
/* GET /api/gamification/leaderboard?class_id=X&period=week|all */
|
||||
function getLeaderboard(req, res) {
|
||||
const classId = req.query.class_id ? Number(req.query.class_id) : null;
|
||||
const period = req.query.period || 'all';
|
||||
|
||||
let rows;
|
||||
if (period === 'week') {
|
||||
const weekAgo = new Date(Date.now() - 7 * 86400000).toISOString();
|
||||
if (classId) {
|
||||
rows = db.prepare(`
|
||||
SELECT u.id, u.name, COALESCE(SUM(xl.amount), 0) AS week_xp, u.xp, u.level,
|
||||
u.streak_current AS streak
|
||||
FROM users u
|
||||
JOIN class_members cm ON cm.user_id = u.id AND cm.class_id = ?
|
||||
LEFT JOIN xp_log xl ON xl.user_id = u.id AND xl.created_at >= ?
|
||||
WHERE u.role = 'student'
|
||||
GROUP BY u.id
|
||||
ORDER BY week_xp DESC
|
||||
LIMIT 30
|
||||
`).all(classId, weekAgo);
|
||||
} else {
|
||||
rows = db.prepare(`
|
||||
SELECT u.id, u.name, COALESCE(SUM(xl.amount), 0) AS week_xp, u.xp, u.level,
|
||||
u.streak_current AS streak
|
||||
FROM users u
|
||||
LEFT JOIN xp_log xl ON xl.user_id = u.id AND xl.created_at >= ?
|
||||
WHERE u.role = 'student'
|
||||
GROUP BY u.id
|
||||
ORDER BY week_xp DESC
|
||||
LIMIT 30
|
||||
`).all(weekAgo);
|
||||
}
|
||||
rows.forEach((r, i) => { r.position = i + 1; r.sort_xp = r.week_xp; });
|
||||
} else {
|
||||
if (classId) {
|
||||
rows = db.prepare(`
|
||||
SELECT u.id, u.name, u.xp, u.level, u.streak_current AS streak
|
||||
FROM users u
|
||||
JOIN class_members cm ON cm.user_id = u.id AND cm.class_id = ?
|
||||
WHERE u.role = 'student'
|
||||
ORDER BY u.xp DESC
|
||||
LIMIT 30
|
||||
`).all(classId);
|
||||
} else {
|
||||
rows = db.prepare(`
|
||||
SELECT u.id, u.name, u.xp, u.level, u.streak_current AS streak
|
||||
FROM users u
|
||||
WHERE u.role = 'student'
|
||||
ORDER BY u.xp DESC
|
||||
LIMIT 30
|
||||
`).all();
|
||||
}
|
||||
rows.forEach((r, i) => { r.position = i + 1; r.sort_xp = r.xp; });
|
||||
}
|
||||
|
||||
// add rank names
|
||||
rows.forEach(r => { r.rank = rankName(r.level || 1); });
|
||||
|
||||
res.json({ rows, period, class_id: classId });
|
||||
}
|
||||
|
||||
/* GET /api/gamification/xp-history — recent XP log */
|
||||
function getXPHistory(req, res) {
|
||||
const limit = Math.min(50, Number(req.query.limit) || 20);
|
||||
const rows = stmts.xpHistory.all(req.user.id, limit);
|
||||
res.json(rows);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
Admin — XP/coins management, stats, reset
|
||||
═══════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
/* POST /api/gamification/admin/award — award XP or coins to user */
|
||||
function adminAward(req, res) {
|
||||
const { userId, xp, coins, reason } = req.body;
|
||||
if (!userId) return res.status(400).json({ error: 'userId required' });
|
||||
const user = stmts.checkUserById.get(userId);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
if (xp && xp > 0) awardXP(userId, xp, reason || 'Admin award');
|
||||
if (coins && coins > 0) awardCoins(userId, coins, reason || 'Admin award');
|
||||
const updated = stmts.getUserGamInfo.get(userId);
|
||||
res.json({ ok: true, ...updated });
|
||||
}
|
||||
|
||||
/* POST /api/gamification/admin/reset — reset user gamification */
|
||||
const _resetTx = db.transaction((userId) => {
|
||||
stmts.adminResetUser.run(userId);
|
||||
stmts.deleteXpLog.run(userId);
|
||||
stmts.deleteUserAchs.run(userId);
|
||||
stmts.deleteDailyGoals.run(userId);
|
||||
stmts.deleteChallenges.run(userId);
|
||||
stmts.deleteUserPurch.run(userId);
|
||||
});
|
||||
|
||||
function adminReset(req, res) {
|
||||
const { userId } = req.body;
|
||||
if (!userId) return res.status(400).json({ error: 'userId required' });
|
||||
_resetTx(userId);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/* GET /api/gamification/admin/stats — global gamification stats */
|
||||
function adminGamStats(_req, res) {
|
||||
const totalXP = stmts.adminTotalXP.get().v;
|
||||
const totalCoins = stmts.adminTotalCoins.get().v;
|
||||
const avgLevel = stmts.adminAvgLevel.get().v;
|
||||
const achievementCount = stmts.adminAchCount.get().v;
|
||||
const topByXP = stmts.adminTopXP.all();
|
||||
const recentXP = stmts.adminRecentXP.all();
|
||||
const totalPurchases = stmts.adminTotalPurch.get().v;
|
||||
const recentPurchases = stmts.adminRecentPurch.all();
|
||||
res.json({ totalXP, totalCoins, avgLevel, achievementCount, totalPurchases, topByXP, recentXP, recentPurchases });
|
||||
}
|
||||
|
||||
/* GET /api/gamification/admin/user/:id — user gamification details */
|
||||
function adminGetUser(req, res) {
|
||||
const uid = Number(req.params.id);
|
||||
const user = stmts.adminGetUserFull.get(uid);
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
const achievements = stmts.adminGetUserAchs.all(uid);
|
||||
const purchases = stmts.adminGetUserPurch.all(uid);
|
||||
const xpHistory = stmts.adminGetUserXPH.all(uid);
|
||||
res.json({ user, achievements, purchases, xpHistory });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// API handlers
|
||||
getMe, getAchievements, getLeaderboard, getXPHistory,
|
||||
getChallenges, claimChallenge, setGoalTier,
|
||||
getFrames, setFrame,
|
||||
// Admin handlers
|
||||
adminAward, adminReset, adminGamStats, adminGetUser,
|
||||
// Service functions for other controllers
|
||||
onTestFinished, onClassJoined, onLabExperiment, updateDailyGoal, updateChallenges,
|
||||
onLessonComplete, checkRedBookAchievements,
|
||||
awardXP, awardCoins, seedAchievements, checkAchievements,
|
||||
};
|
||||
Reference in New Issue
Block a user