const db = require('../db/db'); const { stripTags } = require('../utils/sanitize'); const { audit } = require('../utils/audit'); /* ── Prepared statements ──────────────────────────────────────────────── */ const stmts = { totalUsers: db.prepare("SELECT COUNT(*) AS n FROM users WHERE role = 'student'"), totalTests: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE status = 'completed'"), avgScore: db.prepare( "SELECT ROUND(AVG(CAST(score AS REAL) / total * 100), 1) AS avg FROM test_sessions WHERE status = 'completed'" ), bySubject: db.prepare(` SELECT s.name, s.slug, COUNT(ts.id) AS tests, ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct FROM test_sessions ts JOIN subjects s ON s.id = ts.subject_id WHERE ts.status = 'completed' GROUP BY s.id ORDER BY s.id `), }; /* ── GET /api/admin/stats ─────────────────────────────────────────────── */ function getStats(_req, res) { res.json({ totalUsers: stmts.totalUsers.get().n, totalTests: stmts.totalTests.get().n, avgScore: stmts.avgScore.get().avg, bySubject: stmts.bySubject.all(), }); } /* ── GET /api/admin/users?page=1&limit=50&role=student&q=name ─────────── */ function getUsers(req, res) { const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 50)); const cursor = Number(req.query.cursor) || 0; const role = req.query.role; const search = req.query.q?.trim(); let where = 'WHERE 1=1'; const args = []; if (role) { where += ' AND u.role = ?'; args.push(role); } if (search) { where += ' AND (u.name LIKE ? OR u.email LIKE ?)'; args.push(`%${search}%`, `%${search}%`); } // Cursor-based pagination if (cursor) { const cursorWhere = where + ' AND u.id < ?'; const cursorArgs = [...args, cursor]; const users = db.prepare(` SELECT u.id, u.name, u.email, u.role, u.created_at, u.last_login, u.is_banned, COUNT(ts.id) AS tests_count, ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct FROM users u LEFT JOIN test_sessions ts ON ts.user_id = u.id AND ts.status = 'completed' ${cursorWhere} GROUP BY u.id ORDER BY u.id DESC LIMIT ? `).all(...cursorArgs, limit); const nextCursor = users.length === limit ? users[users.length - 1].id : null; return res.json({ users, nextCursor, limit }); } // Offset-based (legacy) const page = Math.max(1, Number(req.query.page) || 1); const offset = (page - 1) * limit; const { total } = db.prepare(`SELECT COUNT(*) AS total FROM users u ${where}`).get(...args); const users = db.prepare(` SELECT u.id, u.name, u.email, u.role, u.created_at, u.last_login, u.is_banned, COUNT(ts.id) AS tests_count, ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct FROM users u LEFT JOIN test_sessions ts ON ts.user_id = u.id AND ts.status = 'completed' ${where} GROUP BY u.id ORDER BY u.created_at DESC LIMIT ? OFFSET ? `).all(...args, limit, offset); res.json({ users, total, page, limit }); } /* ── PATCH /api/admin/users/:id/role ─────────────────────────────────── */ function updateRole(req, res) { const { role } = req.body; const allowed = ['student', 'teacher', 'admin', 'free_student']; if (!allowed.includes(role)) return res.status(400).json({ error: `role must be one of: ${allowed.join(', ')}` }); const user = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.id); if (!user) return res.status(404).json({ error: 'User not found' }); if (Number(req.params.id) === req.user.id) return res.status(400).json({ error: 'Cannot change your own role' }); const oldRole = db.prepare('SELECT role FROM users WHERE id = ?').get(req.params.id)?.role; db.prepare('UPDATE users SET role = ?, token_version = token_version + 1 WHERE id = ?').run(role, req.params.id); audit(req, 'user.role_change', `user:${req.params.id}`, `${oldRole} -> ${role}`); res.json({ id: Number(req.params.id), role }); } /* ── GET /api/admin/users/:id/sessions ───────────────────────────────── */ function getUserSessions(req, res) { const user = db.prepare('SELECT id, name, email, role, is_banned FROM users WHERE id = ?').get(req.params.id); if (!user) return res.status(404).json({ error: 'User not found' }); const sessions = db.prepare(` SELECT ts.id, ts.mode, ts.score, ts.total, ts.status, ts.started_at, ts.finished_at, s.name AS subject_name, s.slug AS subject_slug FROM test_sessions ts LEFT JOIN subjects s ON s.id = ts.subject_id WHERE ts.user_id = ? ORDER BY ts.started_at DESC LIMIT 100 `).all(req.params.id); res.json({ user, sessions }); } /* ── GET /api/admin/sessions ─────────────────────────────────────────── */ function getAllSessions(req, res) { const { subject, user_id } = req.query; const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 200)); const offset = Math.max(0, Number(req.query.offset) || 0); const where = ['ts.status = \'completed\'']; const params = []; if (subject) { where.push('s.slug = ?'); params.push(subject); } if (user_id) { where.push('ts.user_id = ?'); params.push(Number(user_id)); } params.push(limit, offset); const sessions = db.prepare(` SELECT ts.id, ts.mode, ts.score, ts.total, ts.status, ts.started_at, ts.finished_at, s.name AS subject_name, s.slug AS subject_slug, u.id AS user_id, u.name AS user_name, u.email AS user_email, ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent, CAST((julianday(ts.finished_at) - julianday(ts.started_at)) * 86400 AS INTEGER) AS duration_sec FROM test_sessions ts LEFT JOIN subjects s ON s.id = ts.subject_id JOIN users u ON u.id = ts.user_id WHERE ${where.join(' AND ')} ORDER BY ts.started_at DESC LIMIT ? OFFSET ? `).all(...params); res.json(sessions); } /* ── GET /api/admin/sessions/:id ─────────────────────────────────────── */ function getSessionDetail(req, res) { const session = db.prepare(` SELECT ts.id, ts.mode, ts.score, ts.total, ts.status, ts.started_at, ts.finished_at, s.name AS subject_name, s.slug AS subject_slug, u.id AS user_id, u.name AS user_name, u.email AS user_email, CAST((julianday(ts.finished_at) - julianday(ts.started_at)) * 86400 AS INTEGER) AS duration_sec FROM test_sessions ts LEFT JOIN subjects s ON s.id = ts.subject_id JOIN users u ON u.id = ts.user_id WHERE ts.id = ? `).get(req.params.id); if (!session) return res.status(404).json({ error: 'Session not found' }); const questions = db.prepare(` SELECT q.id, q.text, q.explanation, q.difficulty, ua.chosen_option_id, ua.is_correct, ua.time_spent_sec, sq.order_index FROM session_questions sq JOIN questions q ON q.id = sq.question_id LEFT JOIN user_answers ua ON ua.session_id = sq.session_id AND ua.question_id = q.id WHERE sq.session_id = ? ORDER BY sq.order_index `).all(req.params.id); if (questions.length) { const ids = questions.map(q => q.id); const allOptions = db.prepare( `SELECT id, question_id, text, is_correct FROM options WHERE question_id IN (${ids.map(() => '?').join(',')}) ORDER BY order_index` ).all(...ids); const byQ = {}; for (const o of allOptions) { (byQ[o.question_id] ||= []).push(o); } session.questions = questions.map(q => ({ ...q, options: byQ[q.id] || [] })); } else { session.questions = []; } res.json(session); } /* ── DELETE /api/admin/users/:id/sessions ────────────────────────────── */ function clearUserSessions(req, res, next) { const uid = Number(req.params.id); try { const user = db.prepare('SELECT id FROM users WHERE id = ?').get(uid); if (!user) return res.status(404).json({ error: 'User not found' }); const sessions = db.prepare('SELECT id FROM test_sessions WHERE user_id = ?').all(uid); const n = sessions.length; if (n > 0) { const stmtNullAsgn = db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?'); const stmtDelAns = db.prepare('DELETE FROM user_answers WHERE session_id = ?'); const stmtDelSQ = db.prepare('DELETE FROM session_questions WHERE session_id = ?'); const stmtDelSess = db.prepare('DELETE FROM test_sessions WHERE id = ?'); db.transaction(() => { for (const { id } of sessions) { stmtNullAsgn.run(id); stmtDelAns.run(id); stmtDelSQ.run(id); stmtDelSess.run(id); } })(); } audit(req, 'user.clear_sessions', `user:${uid}`, `${n} sessions deleted`); res.json({ ok: true, deleted: n }); } catch (err) { next(err); } } /* ── PATCH /api/admin/users/:id ──────────────────────────────────────── */ const bcrypt = require('bcryptjs'); const { BCRYPT_ROUNDS } = require('../config'); async function updateUser(req, res, next) { try { const { name, email, password } = req.body; const uid = Number(req.params.id); const user = db.prepare('SELECT id FROM users WHERE id = ?').get(uid); if (!user) return res.status(404).json({ error: 'User not found' }); if (uid === req.user.id) return res.status(400).json({ error: 'Cannot edit your own data here' }); if (name !== undefined && !name?.trim()) return res.status(400).json({ error: 'Name cannot be empty' }); if (email !== undefined) { if (!email?.trim() || !/\S+@\S+\.\S+/.test(email.trim())) return res.status(400).json({ error: 'Invalid email format' }); const existing = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email.trim().toLowerCase(), uid); if (existing) return res.status(400).json({ error: 'Email already in use' }); } if (password !== undefined && password.length < 6) return res.status(400).json({ error: 'Password must be at least 6 characters' }); const fields = []; const vals = []; if (name !== undefined) { fields.push('name = ?'); vals.push(stripTags(name.trim())); } if (email !== undefined) { fields.push('email = ?'); vals.push(email.trim().toLowerCase()); } if (password !== undefined) { const hash = await bcrypt.hash(password, BCRYPT_ROUNDS); fields.push('password_hash = ?', 'token_version = token_version + 1'); vals.push(hash); } if (!fields.length) return res.status(400).json({ error: 'Nothing to update' }); vals.push(uid); db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...vals); const changed = []; if (name !== undefined) changed.push('name'); if (email !== undefined) changed.push('email'); if (password !== undefined) changed.push('password'); audit(req, 'user.edit', `user:${uid}`, changed.join(', ')); res.json({ ok: true }); } catch (err) { next(err); } } /* ── PATCH /api/admin/users/:id/ban { banned: true|false } ──────────── */ function banUser(req, res) { const uid = Number(req.params.id); if (uid === req.user.id) return res.status(400).json({ error: 'Нельзя заблокировать себя' }); const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.role === 'admin') return res.status(400).json({ error: 'Нельзя заблокировать администратора' }); const banned = req.body.banned ? 1 : 0; db.prepare('UPDATE users SET is_banned = ?, token_version = token_version + 1 WHERE id = ?').run(banned, uid); audit(req, banned ? 'user.ban' : 'user.unban', `user:${uid}`, target.role); res.json({ ok: true, banned: !!banned }); } /* ── DELETE /api/admin/users/:id ─────────────────────────────────────── */ const _deleteUserTx = db.transaction((uid) => { // Tables with REFERENCES users(id) WITHOUT ON DELETE CASCADE: db.prepare('DELETE FROM questions WHERE created_by = ?').run(uid); db.prepare('DELETE FROM assignments WHERE created_by = ?').run(uid); // The rest cascades via ON DELETE CASCADE, but explicitly clean large tables: db.prepare('DELETE FROM notifications WHERE user_id = ?').run(uid); db.prepare('DELETE FROM test_sessions WHERE user_id = ?').run(uid); db.prepare('DELETE FROM users WHERE id = ?').run(uid); }); function deleteUser(req, res) { const uid = Number(req.params.id); if (uid === req.user.id) return res.status(400).json({ error: 'Нельзя удалить себя' }); const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(uid); if (!target) return res.status(404).json({ error: 'User not found' }); if (target.role === 'admin') return res.status(400).json({ error: 'Нельзя удалить администратора' }); const uname = db.prepare('SELECT name, email FROM users WHERE id = ?').get(uid); _deleteUserTx(uid); audit(req, 'user.delete', `user:${uid}`, `${uname?.name} (${uname?.email})`); res.json({ ok: true }); } /* ── GET /api/admin/features ─────────────────────────────────────────── */ function getFeatures(_req, res) { const rows = db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'").all(); const features = {}; for (const r of rows) { const name = r.key.replace('feature_', '').replace('_enabled', ''); features[name] = r.value === '1'; } res.json(features); } /* ── PATCH /api/admin/features ──────────────────────────────────────── */ function updateFeatures(req, res) { const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection', 'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom']; const updates = req.body; const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)"); const changed = []; for (const [name, enabled] of Object.entries(updates)) { if (!allowed.includes(name)) continue; stmt.run(`feature_${name}_enabled`, enabled ? '1' : '0'); changed.push(`${name}=${enabled ? 'on' : 'off'}`); } if (changed.length) audit(req, 'features.update', null, changed.join(', ')); res.json({ ok: true }); } /* ── GET /api/admin/free-student-features ────────────────────────────── */ const FREE_STUDENT_MODULES = [ 'gamification', 'hangman', 'crossword', 'pet', 'red_book', 'collection', 'lab', 'knowledge_map', 'flashcards', 'board', 'biochem', 'live_quiz', ]; function getFreeStudentFeatures(_req, res) { const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get(); let features = {}; if (row?.value) { try { features = JSON.parse(row.value); } catch {} } // Default: all enabled const result = {}; for (const m of FREE_STUDENT_MODULES) { result[m] = features[m] !== false; } res.json(result); } /* ── PATCH /api/admin/free-student-features ─────────────────────────── */ function updateFreeStudentFeatures(req, res) { const updates = req.body; const row = db.prepare("SELECT value FROM app_settings WHERE key = 'free_student_features'").get(); let current = {}; if (row?.value) { try { current = JSON.parse(row.value); } catch {} } for (const [key, val] of Object.entries(updates)) { if (!FREE_STUDENT_MODULES.includes(key)) continue; current[key] = Boolean(val); } db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('free_student_features', ?)").run(JSON.stringify(current)); res.json({ ok: true }); } /* ── GET /api/admin/audit-log ───────────────────────────────────────── */ function getAuditLog(req, res) { const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100)); const rows = db.prepare(` SELECT al.*, u.name AS admin_name, u.email AS admin_email FROM admin_audit_log al LEFT JOIN users u ON u.id = al.admin_id ORDER BY al.created_at DESC LIMIT ? `).all(limit); res.json(rows); } /* ── DELETE /api/admin/audit-log ────────────────────────────────────── */ function clearAuditLog(req, res) { db.prepare('DELETE FROM admin_audit_log').run(); res.json({ ok: true }); } /* ── GET /api/admin/error-log ──────────────────────────────────────── */ function getErrorLog(req, res) { const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100)); const rows = db.prepare(` SELECT * FROM error_log ORDER BY created_at DESC LIMIT ? `).all(limit); res.json(rows); } /* ── DELETE /api/admin/error-log ───────────────────────────────────── */ function clearErrorLog(req, res) { db.prepare('DELETE FROM error_log').run(); res.json({ ok: true }); } /* ── GET /api/admin/health ─────────────────────────────────────────── */ const os = require('os'); const path = require('path'); const fs = require('fs'); const { DB_PATH, UPLOADS_DIR } = require('../config'); function getHealth(_req, res) { const uptimeSec = process.uptime(); let dbSizeBytes = 0; try { dbSizeBytes = fs.statSync(DB_PATH).size; } catch {} let uploadsSizeBytes = 0; try { const files = fs.readdirSync(UPLOADS_DIR); for (const f of files) { try { uploadsSizeBytes += fs.statSync(path.join(UPLOADS_DIR, f)).size; } catch {} } } catch {} const totalUsers = db.prepare('SELECT COUNT(*) AS n FROM users').get().n; const todaySessions = db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= date('now')").get().n; const totalSessions = db.prepare('SELECT COUNT(*) AS n FROM test_sessions').get().n; const totalQuestions = db.prepare('SELECT COUNT(*) AS n FROM questions').get().n; const recentErrors = db.prepare("SELECT COUNT(*) AS n FROM error_log WHERE created_at >= datetime('now', '-24 hours')").get().n; res.json({ uptime: uptimeSec, memory: { rss: process.memoryUsage().rss, heapUsed: process.memoryUsage().heapUsed, }, db: { sizeBytes: dbSizeBytes, totalUsers, totalSessions, todaySessions, totalQuestions, }, uploads: { sizeBytes: uploadsSizeBytes, }, node: process.version, platform: os.platform(), cpus: os.cpus().length, freeMem: os.freemem(), totalMem: os.totalmem(), recentErrors, }); } /* ── Topics CRUD ─────────────────────────────────────────────────────── */ function getTopics(req, res) { const { subject_id } = req.query; let rows; if (subject_id) { rows = db.prepare(` SELECT t.*, s.name AS subject_name, s.slug AS subject_slug, (SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS question_count FROM topics t JOIN subjects s ON s.id = t.subject_id WHERE t.subject_id = ? ORDER BY t.order_index, t.name `).all(Number(subject_id)); } else { rows = db.prepare(` SELECT t.*, s.name AS subject_name, s.slug AS subject_slug, (SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS question_count FROM topics t JOIN subjects s ON s.id = t.subject_id ORDER BY s.name, t.order_index, t.name `).all(); } res.json(rows); } function createTopic(req, res) { const { subject_id, name } = req.body; if (!subject_id || !name?.trim()) return res.status(400).json({ error: 'subject_id and name required' }); const subj = db.prepare('SELECT id FROM subjects WHERE id = ?').get(Number(subject_id)); if (!subj) return res.status(404).json({ error: 'Subject not found' }); const maxOrder = db.prepare('SELECT MAX(order_index) AS m FROM topics WHERE subject_id = ?').get(Number(subject_id)); const order = (maxOrder?.m || 0) + 1; const r = db.prepare('INSERT INTO topics (subject_id, name, order_index) VALUES (?, ?, ?)').run(Number(subject_id), stripTags(name.trim()), order); audit(req, 'topic.create', `topic:${r.lastInsertRowid}`, name.trim()); res.status(201).json({ id: r.lastInsertRowid, name: name.trim(), order_index: order }); } function updateTopic(req, res) { const id = Number(req.params.id); const topic = db.prepare('SELECT * FROM topics WHERE id = ?').get(id); if (!topic) return res.status(404).json({ error: 'Topic not found' }); const { name, order_index } = req.body; const newName = name !== undefined ? stripTags(name.trim()) : topic.name; const newOrder = order_index !== undefined ? Number(order_index) : topic.order_index; if (!newName) return res.status(400).json({ error: 'Name cannot be empty' }); db.prepare('UPDATE topics SET name = ?, order_index = ? WHERE id = ?').run(newName, newOrder, id); audit(req, 'topic.update', `topic:${id}`, newName); res.json({ ok: true }); } function deleteTopic(req, res) { const id = Number(req.params.id); const topic = db.prepare('SELECT t.*, (SELECT COUNT(*) FROM questions q WHERE q.topic_id = t.id) AS qcount FROM topics t WHERE t.id = ?').get(id); if (!topic) return res.status(404).json({ error: 'Topic not found' }); if (topic.qcount > 0) return res.status(400).json({ error: `Cannot delete topic with ${topic.qcount} questions. Reassign questions first.` }); db.prepare('DELETE FROM topics WHERE id = ?').run(id); audit(req, 'topic.delete', `topic:${id}`, topic.name); res.json({ ok: true }); } /* ── Broadcast notification ──────────────────────────────────────────── */ const { pushNotif } = require('../utils/notifications'); function broadcast(req, res) { const { message, role, link } = req.body; if (!message?.trim()) return res.status(400).json({ error: 'message required' }); const msg = stripTags(message.trim()).slice(0, 500); const lnk = link?.trim() || null; let users; if (role && role !== 'all') { users = db.prepare('SELECT id FROM users WHERE role = ? AND is_banned = 0').all(role); } else { users = db.prepare('SELECT id FROM users WHERE is_banned = 0').all(); } const ins = db.prepare("INSERT INTO notifications (user_id, type, message, link) VALUES (?, 'broadcast', ?, ?)"); db.transaction(() => { for (const u of users) ins.run(u.id, msg, lnk); })(); audit(req, 'broadcast', role || 'all', `${users.length} users: ${msg.slice(0, 80)}`); res.json({ ok: true, sent: users.length }); } module.exports = { getStats, getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail, clearUserSessions, updateUser, banUser, deleteUser, getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures, getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getTopics, createTopic, updateTopic, deleteTopic, broadcast, };