4a424505a8
backend/src/utils/metrics.js: лёгкие in-memory метрики (сброс при рестарте) — всего запросов, req/min (скользящее окно), латентность avg/p50/p95/p99, разбивка по статусам 2xx/3xx/4xx/5xx, топ маршрутов по частоте/латентности/ ошибкам (группировка по шаблону route.path, не по URL). server.js: middleware (на /api, по res 'finish') пишет латентность и статус. adminController.getMetrics + GET /api/admin/metrics (под admin-auth). admin.js: health-страница переведена на refreshHealth/renderHealth (Level 1) + секция «Метрики запросов»: карточки req/min/всего/avg/p95/p99/5xx, цветная полоса статусов, топ медленных/частых/ошибочных маршрутов. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
815 lines
36 KiB
JavaScript
815 lines
36 KiB
JavaScript
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(),
|
|
});
|
|
}
|
|
|
|
/* ── Overview (Phase 3 dashboard) — prepared statements ───────────────── */
|
|
const overviewStmts = {
|
|
newUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE created_at >= datetime('now', '-24 hours')"),
|
|
newSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours')"),
|
|
activeUsers24h: db.prepare("SELECT COUNT(*) AS n FROM users WHERE last_login IS NOT NULL AND last_login >= datetime('now', '-24 hours')"),
|
|
abandonedSessions24h: db.prepare("SELECT COUNT(*) AS n FROM test_sessions WHERE started_at >= datetime('now', '-24 hours') AND status = 'abandoned'"),
|
|
classesTotal: db.prepare('SELECT COUNT(*) AS n FROM classes'),
|
|
stuckSessions: db.prepare(`
|
|
SELECT ts.id, u.name AS user_name, s.name AS subject_name, ts.started_at
|
|
FROM test_sessions ts
|
|
JOIN users u ON u.id = ts.user_id
|
|
LEFT JOIN subjects s ON s.id = ts.subject_id
|
|
WHERE ts.status = 'in_progress'
|
|
AND ts.started_at < datetime('now', '-1 hour')
|
|
ORDER BY ts.started_at
|
|
LIMIT 5
|
|
`),
|
|
// No banned_at column — fall back to audit log for recent bans (last 7 days)
|
|
bannedThisWeek: db.prepare(`
|
|
SELECT u.id, u.name, u.email, al.created_at AS banned_at
|
|
FROM admin_audit_log al
|
|
JOIN users u ON u.id = CAST(SUBSTR(al.target, 6) AS INTEGER)
|
|
WHERE al.action = 'user.ban'
|
|
AND al.created_at >= datetime('now', '-7 days')
|
|
AND u.is_banned = 1
|
|
GROUP BY u.id
|
|
ORDER BY al.created_at DESC
|
|
LIMIT 10
|
|
`),
|
|
topSessions24h: db.prepare(`
|
|
SELECT ts.id, u.name AS user_name, s.name AS subject_name,
|
|
ts.score, ts.total,
|
|
ROUND(CAST(ts.score AS REAL) / ts.total * 100, 1) AS percent,
|
|
ts.finished_at
|
|
FROM test_sessions ts
|
|
JOIN users u ON u.id = ts.user_id
|
|
LEFT JOIN subjects s ON s.id = ts.subject_id
|
|
WHERE ts.status = 'completed'
|
|
AND ts.finished_at >= datetime('now', '-24 hours')
|
|
AND ts.total > 0
|
|
ORDER BY (CAST(ts.score AS REAL) / ts.total) DESC, ts.finished_at DESC
|
|
LIMIT 5
|
|
`),
|
|
worstSessions24h: db.prepare(`
|
|
SELECT ts.id, u.name AS user_name, s.name AS subject_name,
|
|
ts.score, ts.total,
|
|
ROUND(CAST(ts.score AS REAL) / ts.total * 100, 1) AS percent,
|
|
ts.finished_at
|
|
FROM test_sessions ts
|
|
JOIN users u ON u.id = ts.user_id
|
|
LEFT JOIN subjects s ON s.id = ts.subject_id
|
|
WHERE ts.status = 'completed'
|
|
AND ts.finished_at >= datetime('now', '-24 hours')
|
|
AND ts.total > 0
|
|
ORDER BY (CAST(ts.score AS REAL) / ts.total) ASC, ts.finished_at DESC
|
|
LIMIT 5
|
|
`),
|
|
sparkUsers7d: db.prepare(`
|
|
SELECT date(created_at) AS d, COUNT(*) AS n FROM users
|
|
WHERE created_at >= date('now', '-6 days')
|
|
GROUP BY d ORDER BY d
|
|
`),
|
|
sparkSessions7d: db.prepare(`
|
|
SELECT date(started_at) AS d, COUNT(*) AS n FROM test_sessions
|
|
WHERE started_at >= date('now', '-6 days')
|
|
GROUP BY d ORDER BY d
|
|
`),
|
|
sparkActiveUsers7d: db.prepare(`
|
|
SELECT date(last_login) AS d, COUNT(DISTINCT id) AS n FROM users
|
|
WHERE last_login IS NOT NULL AND last_login >= date('now', '-6 days')
|
|
GROUP BY d ORDER BY d
|
|
`),
|
|
inventory: db.prepare(`
|
|
SELECT
|
|
(SELECT COUNT(*) FROM questions) AS questions,
|
|
(SELECT COUNT(*) FROM tests) AS tests,
|
|
(SELECT COUNT(*) FROM courses) AS courses,
|
|
(SELECT COUNT(*) FROM classes) AS classes
|
|
`),
|
|
sessionsBySubject24h: db.prepare(`
|
|
SELECT s.slug, s.name, COUNT(*) AS n FROM test_sessions ts
|
|
JOIN subjects s ON s.id = ts.subject_id
|
|
WHERE ts.started_at >= datetime('now', '-24 hours')
|
|
GROUP BY s.id ORDER BY n DESC
|
|
`),
|
|
};
|
|
|
|
/* ── GET /api/admin/overview ──────────────────────────────────────────── */
|
|
function getOverview(_req, res) {
|
|
try {
|
|
res.json({
|
|
newUsers24h: overviewStmts.newUsers24h.get().n,
|
|
newSessions24h: overviewStmts.newSessions24h.get().n,
|
|
activeUsers24h: overviewStmts.activeUsers24h.get().n,
|
|
classesTotal: overviewStmts.classesTotal.get().n,
|
|
abandonedSessions24h: overviewStmts.abandonedSessions24h.get().n,
|
|
stuckSessions: overviewStmts.stuckSessions.all(),
|
|
bannedThisWeek: overviewStmts.bannedThisWeek.all(),
|
|
topSessions24h: overviewStmts.topSessions24h.all(),
|
|
worstSessions24h: overviewStmts.worstSessions24h.all(),
|
|
inventory: overviewStmts.inventory.get(),
|
|
sessionsBySubject24h: overviewStmts.sessionsBySubject24h.all(),
|
|
sparks: {
|
|
users: overviewStmts.sparkUsers7d.all(),
|
|
sessions: overviewStmts.sparkSessions7d.all(),
|
|
active: overviewStmts.sparkActiveUsers7d.all(),
|
|
},
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
|
|
/* ── Global search (Phase 4 command palette) — prepared statements ────── */
|
|
const searchStmts = {
|
|
users: db.prepare(`
|
|
SELECT id, name, email, role
|
|
FROM users
|
|
WHERE name LIKE ? OR email LIKE ?
|
|
ORDER BY (CASE WHEN name LIKE ? THEN 0 ELSE 1 END), id DESC
|
|
LIMIT 5
|
|
`),
|
|
tests: db.prepare(`
|
|
SELECT id, title AS name, subject_slug
|
|
FROM tests
|
|
WHERE title LIKE ?
|
|
ORDER BY id DESC
|
|
LIMIT 3
|
|
`),
|
|
classes: db.prepare(`
|
|
SELECT id, name, invite_code AS code
|
|
FROM classes
|
|
WHERE name LIKE ? OR invite_code LIKE ?
|
|
ORDER BY id DESC
|
|
LIMIT 3
|
|
`),
|
|
};
|
|
|
|
/* ── GET /api/admin/search?q=X ────────────────────────────────────────── */
|
|
function globalSearch(req, res) {
|
|
const q = (req.query.q || '').trim();
|
|
if (q.length < 2) {
|
|
return res.json({ users: [], tests: [], classes: [] });
|
|
}
|
|
try {
|
|
const like = `%${q}%`;
|
|
const prefix = `${q}%`;
|
|
res.json({
|
|
users: searchStmts.users.all(like, like, prefix),
|
|
tests: searchStmts.tests.all(like),
|
|
classes: searchStmts.classes.all(like, like),
|
|
});
|
|
} catch (err) {
|
|
console.error('[admin.search]', err.message);
|
|
res.status(500).json({ error: 'Search failed' });
|
|
}
|
|
}
|
|
|
|
/* ── 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/sessions/:id ──────────────────────────────────── */
|
|
const _deleteSessionTx = db.transaction((sid) => {
|
|
// assignment_sessions references test_sessions with ON DELETE SET NULL,
|
|
// but we explicitly null it so the assignment slot stays usable.
|
|
db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?').run(sid);
|
|
// user_answers / session_questions cascade via ON DELETE CASCADE,
|
|
// but delete explicitly for visibility and to mirror clearUserSessions().
|
|
db.prepare('DELETE FROM user_answers WHERE session_id = ?').run(sid);
|
|
db.prepare('DELETE FROM session_questions WHERE session_id = ?').run(sid);
|
|
db.prepare('DELETE FROM test_sessions WHERE id = ?').run(sid);
|
|
});
|
|
|
|
function deleteSession(req, res, next) {
|
|
const sid = Number(req.params.id);
|
|
if (!Number.isInteger(sid) || sid <= 0)
|
|
return res.status(400).json({ error: 'Invalid session id' });
|
|
try {
|
|
const sess = db.prepare('SELECT id, user_id, mode FROM test_sessions WHERE id = ?').get(sid);
|
|
if (!sess) return res.status(404).json({ error: 'Session not found' });
|
|
_deleteSessionTx(sid);
|
|
audit(req, 'session.delete', `session:${sid}`, `user:${sess.user_id} mode:${sess.mode}`);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
|
|
/* ── 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);
|
|
// Персональные правила доступа к контенту (нет FK — чистим вручную):
|
|
db.prepare("DELETE FROM content_access WHERE scope = 'student' AND target_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',
|
|
'gamification'];
|
|
const updates = req.body;
|
|
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
|
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
|
|
let touchedGamification = false;
|
|
for (const [name, enabled] of Object.entries(updates)) {
|
|
if (!allowed.includes(name)) continue;
|
|
const settingKey = `feature_${name}_enabled`;
|
|
const oldRow = getOld.get(settingKey);
|
|
const oldVal = oldRow ? oldRow.value : null;
|
|
const newVal = enabled ? '1' : '0';
|
|
stmt.run(settingKey, newVal);
|
|
audit(req, 'feature.update', `feature:${name}`, `${oldVal} -> ${newVal}`);
|
|
if (name === 'gamification') touchedGamification = true;
|
|
}
|
|
// Invalidate the gamification cache so the kill-switch takes effect
|
|
// immediately (otherwise awardXP keeps running for up to 30s).
|
|
if (touchedGamification) {
|
|
try {
|
|
const { invalidateGamificationCache } = require('./gamification/_shared');
|
|
invalidateGamificationCache();
|
|
} catch { /* defensive — shouldn't fail */ }
|
|
}
|
|
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 { execSync } = require('child_process');
|
|
const { monitorEventLoopDelay } = require('perf_hooks');
|
|
const sse = require('../sse');
|
|
const { DB_PATH, UPLOADS_DIR, NODE_ENV } = require('../config');
|
|
|
|
// Монитор лага event-loop (включается один раз при загрузке модуля).
|
|
const _eluMonitor = monitorEventLoopDelay({ resolution: 20 });
|
|
_eluMonitor.enable();
|
|
|
|
// Версия приложения + git-commit (кэшируются один раз).
|
|
const _appVersion = (() => { try { return require('../../package.json').version; } catch { return '?'; } })();
|
|
const _gitCommit = (() => {
|
|
try { return execSync('git rev-parse --short HEAD', { cwd: __dirname, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); }
|
|
catch { return null; }
|
|
})();
|
|
|
|
function _dirSize(dir) {
|
|
let total = 0;
|
|
try { for (const f of fs.readdirSync(dir)) { try { total += fs.statSync(path.join(dir, f)).size; } catch {} } } catch {}
|
|
return total;
|
|
}
|
|
|
|
// Топ таблиц БД по числу строк (исключая служебные sqlite_* и _migrations).
|
|
function _dbTables() {
|
|
try {
|
|
const tables = db.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '\\_%' ESCAPE '\\'"
|
|
).all();
|
|
const rows = tables.map(t => {
|
|
let n = 0;
|
|
try { n = db.prepare(`SELECT COUNT(*) AS n FROM "${t.name}"`).get().n; } catch {}
|
|
return { name: t.name, rows: n };
|
|
});
|
|
rows.sort((a, b) => b.rows - a.rows);
|
|
return rows.slice(0, 12);
|
|
} catch { return []; }
|
|
}
|
|
|
|
function getHealth(_req, res) {
|
|
const uptimeSec = process.uptime();
|
|
const mem = process.memoryUsage();
|
|
const dbSizeBytes = (() => { try { return fs.statSync(DB_PATH).size; } catch { return 0; } })();
|
|
const walSizeBytes = (() => { try { return fs.statSync(DB_PATH + '-wal').size; } catch { return 0; } })();
|
|
const uploadsSizeBytes = _dirSize(UPLOADS_DIR);
|
|
|
|
// Свободное место на разделе, где лежит БД.
|
|
let disk = null;
|
|
try { const s = fs.statfsSync(path.dirname(path.resolve(DB_PATH))); disk = { freeBytes: s.bavail * s.bsize, totalBytes: s.blocks * s.bsize }; } catch {}
|
|
|
|
const totalMem = os.totalmem(), freeMem = os.freemem();
|
|
const memPercent = totalMem ? (totalMem - freeMem) / totalMem : 0;
|
|
const eventLoopLagMs = _eluMonitor.mean / 1e6;
|
|
|
|
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;
|
|
|
|
let sseStats = { users: 0, guests: 0, connections: 0 };
|
|
try { sseStats = sse.stats(); } catch {}
|
|
|
|
// Вердикт здоровья по порогам.
|
|
const reasons = [];
|
|
let status = 'ok';
|
|
const warn = (m) => { reasons.push(m); if (status === 'ok') status = 'warning'; };
|
|
const crit = (m) => { reasons.push(m); status = 'critical'; };
|
|
if (memPercent > 0.92) crit(`Память ${Math.round(memPercent * 100)}%`);
|
|
else if (memPercent > 0.80) warn(`Память ${Math.round(memPercent * 100)}%`);
|
|
if (disk) {
|
|
if (disk.freeBytes < 500e6) crit('Мало места на диске (<500 МБ)');
|
|
else if (disk.freeBytes < 2e9) warn('Места на диске <2 ГБ');
|
|
}
|
|
if (recentErrors > 50) crit(`${recentErrors} ошибок за 24ч`);
|
|
else if (recentErrors > 5) warn(`${recentErrors} ошибок за 24ч`);
|
|
if (eventLoopLagMs > 200) crit(`Лаг event-loop ${eventLoopLagMs.toFixed(0)} мс`);
|
|
else if (eventLoopLagMs > 70) warn(`Лаг event-loop ${eventLoopLagMs.toFixed(0)} мс`);
|
|
if (dbSizeBytes > 1.5e9) warn('БД >1.5 ГБ');
|
|
|
|
res.json({
|
|
status, reasons,
|
|
uptime: uptimeSec,
|
|
startedAt: new Date(Date.now() - uptimeSec * 1000).toISOString(),
|
|
memory: { rss: mem.rss, heapUsed: mem.heapUsed, heapTotal: mem.heapTotal },
|
|
memPercent, eventLoopLagMs,
|
|
loadavg: os.loadavg(),
|
|
disk,
|
|
db: { sizeBytes: dbSizeBytes, walBytes: walSizeBytes, totalUsers, totalSessions, todaySessions, totalQuestions, tables: _dbTables() },
|
|
uploads: { sizeBytes: uploadsSizeBytes },
|
|
sse: sseStats,
|
|
node: process.version,
|
|
platform: os.platform(),
|
|
arch: os.arch(),
|
|
cpus: os.cpus().length,
|
|
freeMem, totalMem,
|
|
pid: process.pid,
|
|
env: NODE_ENV,
|
|
version: _appVersion,
|
|
commit: _gitCommit,
|
|
recentErrors,
|
|
});
|
|
}
|
|
|
|
/* ── GET /api/admin/metrics — метрики HTTP-запросов (Level 2) ──────────── */
|
|
const metrics = require('../utils/metrics');
|
|
function getMetrics(_req, res) {
|
|
res.json(metrics.snapshot());
|
|
}
|
|
|
|
/* ── 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, getOverview, globalSearch,
|
|
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
|
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
|
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
|
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth, getMetrics,
|
|
getTopics, createTopic, updateTopic, deleteTopic,
|
|
broadcast,
|
|
};
|