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:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+548
View File
@@ -0,0 +1,548 @@
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'];
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,
};
@@ -0,0 +1,80 @@
const db = require('../db/db');
function teacherOverview(req, res) {
const user = req.user;
const classId = req.query.classId ? Number(req.query.classId) : null;
const classes = user.role === 'admin'
? db.prepare('SELECT id, name FROM classes ORDER BY name').all()
: db.prepare('SELECT id, name FROM classes WHERE teacher_id = ? ORDER BY name').all(user.id);
if (!classId) return res.json({ classes, data: null });
if (user.role !== 'admin') {
const cls = db.prepare('SELECT id FROM classes WHERE id = ? AND teacher_id = ?').get(classId, user.id);
if (!cls) return res.status(403).json({ error: 'Forbidden' });
}
const overview = db.prepare(`
SELECT
COUNT(DISTINCT cm.user_id) AS students,
COUNT(DISTINCT CASE WHEN ts.status='completed' THEN ts.id END) AS sessions,
ROUND(AVG(CASE WHEN ts.status='completed' AND ts.total>0
THEN ts.score*100.0/ts.total END), 1) AS avgScore
FROM class_members cm
LEFT JOIN test_sessions ts ON ts.user_id = cm.user_id
WHERE cm.class_id = ?
`).get(classId);
const scoreByWeek = db.prepare(`
SELECT
strftime('%Y-W%W', ts.finished_at) AS week,
ROUND(AVG(ts.score*100.0/ts.total), 1) AS avg,
COUNT(*) AS sessions
FROM test_sessions ts
JOIN class_members cm ON cm.user_id = ts.user_id AND cm.class_id = ?
WHERE ts.status='completed' AND ts.total>0
AND ts.finished_at >= datetime('now','-56 days')
GROUP BY week ORDER BY week
`).all(classId);
const hardQuestions = db.prepare(`
SELECT q.id, q.text, q.difficulty,
COUNT(ua.id) AS attempts,
ROUND(SUM(CASE WHEN ua.is_correct=0 THEN 1.0 ELSE 0 END)*100/COUNT(ua.id),1) AS errorRate,
t.name AS topic
FROM user_answers ua
JOIN questions q ON q.id = ua.question_id
JOIN test_sessions ts ON ts.id = ua.session_id
JOIN class_members cm ON cm.user_id = ts.user_id AND cm.class_id = ?
LEFT JOIN topics t ON t.id = q.topic_id
WHERE ts.status='completed'
GROUP BY q.id HAVING attempts >= 3
ORDER BY errorRate DESC LIMIT 10
`).all(classId);
const heatmap = db.prepare(`
SELECT date(ts.started_at) AS day,
COUNT(DISTINCT ts.user_id) AS students,
COUNT(ts.id) AS sessions
FROM test_sessions ts
JOIN class_members cm ON cm.user_id = ts.user_id AND cm.class_id = ?
WHERE ts.started_at >= datetime('now','-90 days')
GROUP BY day ORDER BY day
`).all(classId);
const assignments = db.prepare(`
SELECT a.id, a.title, a.deadline,
COUNT(DISTINCT cm.user_id) AS total,
COUNT(DISTINCT CASE WHEN ass.session_id IS NOT NULL THEN ass.user_id END) AS done
FROM assignments a
JOIN class_members cm ON cm.class_id = a.class_id
LEFT JOIN assignment_sessions ass ON ass.assignment_id=a.id AND ass.user_id=cm.user_id
WHERE a.class_id = ?
GROUP BY a.id ORDER BY a.created_at DESC LIMIT 10
`).all(classId);
res.json({ classes, overview, scoreByWeek, hardQuestions, heatmap, assignments });
}
module.exports = { teacherOverview };
@@ -0,0 +1,652 @@
const db = require('../db/db');
const { pushNotif } = require('../utils/notifications');
const { stripTags } = require('../utils/sanitize');
const { SESSION_MODES } = require('../constants');
const VALID_ASSIGN_MODES = SESSION_MODES;
/* ── Prepared statements (module-level to avoid re-parsing per request) ── */
const stmts = {
getTestSubject: db.prepare('SELECT subject_slug FROM tests WHERE id = ?'),
getFileSubject: db.prepare('SELECT subject_slug FROM files WHERE id = ?'),
getClass: db.prepare('SELECT id, teacher_id FROM classes WHERE id = ?'),
getClassMembers: db.prepare('SELECT user_id FROM class_members WHERE class_id = ?'),
getSubjectBySlug: db.prepare('SELECT id FROM subjects WHERE slug = ?'),
countCompletedSess: db.prepare(`
SELECT COUNT(*) AS n FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
WHERE ax.assignment_id = ? AND ax.user_id = ?
`),
getInProgressSess: db.prepare(`
SELECT ax.session_id FROM assignment_sessions ax
JOIN test_sessions ts ON ts.id = ax.session_id AND ts.status = 'in_progress'
WHERE ax.assignment_id = ? AND ax.user_id = ?
ORDER BY ax.id DESC LIMIT 1
`),
insertSession: db.prepare('INSERT INTO test_sessions (user_id, subject_id, mode, total) VALUES (?, ?, ?, ?)'),
insertSessionQ: db.prepare('INSERT INTO session_questions (session_id, question_id, order_index) VALUES (?, ?, ?)'),
insertAssignSess: db.prepare('INSERT INTO assignment_sessions (assignment_id, user_id, session_id, attempt_num) VALUES (?, ?, ?, ?)'),
notifyClassMembers: db.prepare('SELECT user_id FROM class_members WHERE class_id = ? AND user_id != ?'),
};
/* ── POST /api/classes/:id/assignments ── create assignment ─────────────── */
function createAssignment(req, res) {
const { title, topic_id, deadline, test_id, file_id, is_homework = 0 } = req.body;
const mode = req.body.mode || 'exam';
const count = Number(req.body.count) || 25;
const max_attempts = Math.max(0, Math.min(10, Number(req.body.max_attempts) || 0));
let { subject_slug } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
const cleanTitle = stripTags(title.trim());
if (!VALID_ASSIGN_MODES.has(mode)) return res.status(400).json({ error: 'mode must be exam, practice, repeat or ct' });
if (!Number.isInteger(count) || count < 1 || count > 200)
return res.status(400).json({ error: 'count must be an integer between 1 and 200' });
if (deadline && isNaN(Date.parse(deadline)))
return res.status(400).json({ error: 'deadline must be a valid date' });
if (test_id) {
const t = stmts.getTestSubject.get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (file_id && !subject_slug) {
const f = stmts.getFileSubject.get(file_id);
if (f?.subject_slug) subject_slug = f.subject_slug;
}
// Upload-only homework doesn't require subject
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
if (!subject_slug) subject_slug = 'other';
const cls = stmts.getClass.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Class not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const r = db.prepare(`
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework, max_attempts)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(cls.id, cleanTitle, subject_slug, mode, Number(count), topic_id || null, deadline || null, req.user.id, test_id || null, file_id || null, is_homework ? 1 : 0, max_attempts);
// Уведомления всем членам класса (batch via transaction)
const members = stmts.getClassMembers.all(cls.id);
const notifMsg = `Новое задание: «${cleanTitle}»`;
const insertNotif = db.transaction(() => {
members.forEach(m => pushNotif(m.user_id, 'assignment', notifMsg, '/dashboard'));
});
insertNotif();
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── PUT /api/assignments/:id ── update ───────────────────────────────── */
function updateAssignment(req, res) {
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { title, mode, count, deadline } = req.body;
let { subject_slug, test_id } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
test_id = test_id ? Number(test_id) : null;
if (test_id) {
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
db.prepare(`
UPDATE assignments SET title = ?, subject_slug = ?, mode = ?, count = ?, deadline = ?, test_id = ? WHERE id = ?
`).run(stripTags(title.trim()), subject_slug, mode || 'exam', Number(count) || 25, deadline || null, test_id, req.params.id);
res.json({ ok: true });
}
/* ── DELETE /api/assignments/:id ──────────────────────────────────────── */
function deleteAssignment(req, res) {
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM assignments WHERE id = ?').run(req.params.id);
res.json({ ok: true });
}
/* ── GET /api/assignments/teacher ── teacher: own created assignments ──── */
function teacherAssignments(req, res) {
const isAdmin = req.user.role === 'admin';
// Админ видит все задания без ограничений
if (isAdmin) {
const rows = db.prepare(`
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.test_id, t.title AS test_title,
a.file_id, f.title AS file_title,
a.user_id AS target_user_id, tu.name AS target_user_name,
COALESCE(c.name, 'Личное задание') AS class_name,
COALESCE(c.id, 0) AS class_id,
CASE WHEN a.user_id IS NOT NULL THEN 1 ELSE COUNT(DISTINCT cm.user_id) END AS total_members,
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS completed_count
FROM assignments a
LEFT JOIN classes c ON c.id = a.class_id AND a.class_id IS NOT NULL
LEFT JOIN users tu ON tu.id = a.user_id
LEFT JOIN tests t ON t.id = a.test_id
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN class_members cm ON cm.class_id = c.id
LEFT JOIN assignment_sessions ases ON ases.assignment_id = a.id
LEFT JOIN test_sessions ts ON ts.id = ases.session_id
GROUP BY a.id
ORDER BY a.created_at DESC
`).all();
return res.json(rows);
}
// Учитель видит только свои задания:
// - классовые (любые свои)
// - личные — только для учеников из своих классов
const rows = db.prepare(`
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.test_id, t.title AS test_title,
a.file_id, f.title AS file_title,
a.user_id AS target_user_id, tu.name AS target_user_name,
COALESCE(c.name, 'Личное задание') AS class_name,
COALESCE(c.id, 0) AS class_id,
CASE WHEN a.user_id IS NOT NULL THEN 1 ELSE COUNT(DISTINCT cm.user_id) END AS total_members,
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS completed_count
FROM assignments a
LEFT JOIN classes c ON c.id = a.class_id AND a.class_id IS NOT NULL
LEFT JOIN users tu ON tu.id = a.user_id
LEFT JOIN tests t ON t.id = a.test_id
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN class_members cm ON cm.class_id = c.id
LEFT JOIN assignment_sessions ases ON ases.assignment_id = a.id
LEFT JOIN test_sessions ts ON ts.id = ases.session_id
WHERE a.created_by = ?
AND (
a.class_id IS NOT NULL
OR (
a.user_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM class_members cm2
JOIN classes c2 ON c2.id = cm2.class_id
WHERE cm2.user_id = a.user_id AND c2.teacher_id = ?
)
)
)
GROUP BY a.id
ORDER BY a.created_at DESC
`).all(req.user.id, req.user.id);
res.json(rows);
}
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
function myAssignments(req, res) {
const uid = req.user.id;
const rows = db.prepare(`
SELECT * FROM (
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.file_id, f.title AS file_title,
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done,
a.is_homework, a.max_attempts,
(SELECT COUNT(*) FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
WHERE ax.assignment_id = a.id AND ax.user_id = cm.user_id) AS attempts_used
FROM class_members cm
JOIN classes c ON c.id = cm.class_id
JOIN users u ON u.id = c.teacher_id
JOIN assignments a ON a.class_id = c.id AND a.user_id IS NULL
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = cm.user_id
AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = cm.user_id)
LEFT JOIN test_sessions ts ON ts.id = latest.session_id
WHERE cm.user_id = ?
UNION ALL
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.file_id, f.title AS file_title,
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
CASE WHEN latest.session_id IS NULL THEN 0 ELSE 1 END AS done,
a.is_homework, a.max_attempts,
(SELECT COUNT(*) FROM assignment_sessions ax
JOIN test_sessions tx ON tx.id = ax.session_id AND tx.status = 'completed'
WHERE ax.assignment_id = a.id AND ax.user_id = ?) AS attempts_used
FROM assignments a
JOIN users u ON u.id = a.created_by
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN assignment_sessions latest ON latest.assignment_id = a.id AND latest.user_id = ?
AND latest.id = (SELECT MAX(id) FROM assignment_sessions WHERE assignment_id = a.id AND user_id = ?)
LEFT JOIN test_sessions ts ON ts.id = latest.session_id
WHERE a.user_id = ?
) ORDER BY done ASC, deadline ASC, created_at DESC
`).all(uid, uid, uid, uid, uid);
res.json(rows);
}
/* ── POST /api/assignments/:id/start ── student starts session ─────────── */
function startAssignment(req, res) {
const uid = req.user.id;
const assignment = db.prepare(`
SELECT a.* FROM assignments a
WHERE a.id = ?
AND (
(a.class_id IS NOT NULL AND EXISTS (
SELECT 1 FROM class_members WHERE class_id = a.class_id AND user_id = ?
))
OR a.user_id = ?
)
`).get(req.params.id, uid, uid);
if (!assignment) return res.status(404).json({ error: 'Assignment not found or not your class' });
// Deadline check: reject if deadline has already passed
if (assignment.deadline) {
const dl = new Date(assignment.deadline.includes('T') ? assignment.deadline : assignment.deadline.replace(' ', 'T') + 'Z');
if (dl < new Date()) return res.status(403).json({ error: 'Срок выполнения задания истёк' });
}
// File-only assignment: just return the download URL, no session needed
if (assignment.file_id && !assignment.test_id) {
return res.json({ is_file: true, file_id: assignment.file_id });
}
// assignment mode → session mode mapping
const SESSION_MODE = { exam: 'exam', practice: 'practice', repeat: 'practice', ct: 'exam' };
const sessionMode = SESSION_MODE[assignment.mode] || 'exam';
// Count completed attempts for this student
const completedCount = stmts.countCompletedSess.get(req.params.id, uid).n;
// Check attempt limit
const maxAttempts = assignment.max_attempts || 0;
if (maxAttempts > 0 && completedCount >= maxAttempts) {
return res.status(403).json({
error: 'Исчерпан лимит попыток',
attempts_used: completedCount,
max_attempts: maxAttempts,
});
}
// Check for an existing in-progress session
const inProgress = stmts.getInProgressSess.get(req.params.id, uid);
if (inProgress?.session_id) {
return res.json({
session_id: inProgress.session_id,
already_started: true,
status: 'in_progress',
assignment_mode: assignment.mode,
attempts_used: completedCount,
max_attempts: maxAttempts,
});
}
const subject = stmts.getSubjectBySlug.get(assignment.subject_slug);
if (!subject) return res.status(400).json({ error: 'Invalid subject' });
let questionIds;
if (assignment.test_id) {
// Use exact questions from the pre-made test (in defined order)
questionIds = db.prepare(
'SELECT question_id FROM test_questions WHERE test_id = ? ORDER BY order_index'
).all(assignment.test_id).map(r => r.question_id);
} else if (assignment.mode === 'ct') {
// CT mode: Part A (single/true_false) first, then Part B (multi/short_answer)
const baseWhere = `subject_id = ?${assignment.topic_id ? ' AND topic_id = ?' : ''}`;
const baseArgs = assignment.topic_id ? [subject.id, assignment.topic_id] : [subject.id];
const half = Math.ceil(assignment.count / 2);
const partA = db.prepare(`SELECT id FROM questions WHERE ${baseWhere} AND type IN ('single','true_false') ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, half).map(q => q.id);
const partB = db.prepare(`SELECT id FROM questions WHERE ${baseWhere} AND type IN ('multi','short_answer') ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, assignment.count - partA.length).map(q => q.id);
const got = partA.length + partB.length;
const usedIds = [...partA, ...partB];
const extra = (got < assignment.count && usedIds.length > 0)
? db.prepare(`SELECT id FROM questions WHERE ${baseWhere} AND id NOT IN (${usedIds.map(() => '?').join(',')}) ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, ...usedIds, assignment.count - got).map(q => q.id)
: [];
questionIds = [...partA, ...partB, ...extra];
} else {
const baseWhere = `subject_id = ?${assignment.topic_id ? ' AND topic_id = ?' : ''}`;
const baseArgs = assignment.topic_id ? [subject.id, assignment.topic_id] : [subject.id];
questionIds = db.prepare(`SELECT id FROM questions WHERE ${baseWhere} ORDER BY RANDOM() LIMIT ?`)
.all(...baseArgs, assignment.count).map(q => q.id);
}
if (!questionIds.length) return res.status(400).json({ error: 'No questions available' });
const session_id = db.transaction(() => {
const { lastInsertRowid: sid } = stmts.insertSession.run(uid, subject.id, sessionMode, questionIds.length);
questionIds.forEach((qid, i) => stmts.insertSessionQ.run(sid, qid, i));
stmts.insertAssignSess.run(req.params.id, uid, sid, completedCount + 1);
return sid;
})();
res.json({
session_id,
assignment_mode: assignment.mode,
attempt_num: completedCount + 1,
attempts_used: completedCount,
max_attempts: maxAttempts,
});
}
/* ── GET /api/assignments/:id/results ── teacher view ──────────────────── */
function assignmentResults(req, res) {
const a = db.prepare(`
SELECT a.*, COALESCE(c.teacher_id, a.created_by) AS teacher_id, c.name AS class_name
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
let results;
if (a.user_id) {
// Direct assignment: single student — pick best attempt
results = db.prepare(`
SELECT u.id, u.name, u.email,
best.session_id,
best.score, best.total, best.session_status, best.finished_at,
best.percent,
best.attempts_used
FROM users u
LEFT JOIN (
SELECT ases.user_id,
ases.session_id,
ts.score, ts.total, ts.status AS session_status, ts.finished_at,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
COUNT(*) OVER (PARTITION BY ases.assignment_id, ases.user_id) AS attempts_used,
ROW_NUMBER() OVER (PARTITION BY ases.user_id ORDER BY ts.score DESC, ts.finished_at DESC) AS rn
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = ?
) best ON best.user_id = u.id AND best.rn = 1
WHERE u.id = ?
`).all(req.params.id, a.user_id);
} else {
results = db.prepare(`
SELECT u.id, u.name, u.email,
best.session_id,
best.score, best.total, best.session_status, best.finished_at,
best.percent,
best.attempts_used
FROM class_members cm
JOIN users u ON u.id = cm.user_id
LEFT JOIN (
SELECT ases.user_id,
ases.session_id,
ts.score, ts.total, ts.status AS session_status, ts.finished_at,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
COUNT(*) OVER (PARTITION BY ases.assignment_id, ases.user_id) AS attempts_used,
ROW_NUMBER() OVER (PARTITION BY ases.user_id ORDER BY ts.score DESC, ts.finished_at DESC) AS rn
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = ?
) best ON best.user_id = cm.user_id AND best.rn = 1
WHERE cm.class_id = ?
ORDER BY
CASE WHEN best.percent IS NULL THEN 1 ELSE 0 END,
best.percent DESC, u.name
`).all(req.params.id, a.class_id);
}
res.json({ assignment: a, results });
}
/* ── GET /api/assignments/:id/question-stats ── per-question error rates ── */
function assignmentQuestionStats(req, res) {
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id, a.class_id, a.user_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(req.params.id);
if (!a) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
// All completed sessions for this assignment
const sessions = db.prepare(`
SELECT ases.session_id FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = ? AND ts.status = 'completed'
`).all(req.params.id);
if (!sessions.length) return res.json({ stats: [] });
const sessionIds = sessions.map(s => s.session_id);
const placeholders = sessionIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT q.id AS question_id,
q.text AS question_text,
q.type,
COUNT(ua.id) AS total,
SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS wrong,
ROUND(
CAST(SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS REAL)
/ COUNT(ua.id) * 100
, 0) AS error_pct
FROM user_answers ua
JOIN questions q ON q.id = ua.question_id
WHERE ua.session_id IN (${placeholders})
GROUP BY ua.question_id
ORDER BY error_pct DESC, wrong DESC
`).all(...sessionIds);
res.json({ stats: rows, session_count: sessionIds.length });
}
/* ── POST /api/assignments ── direct assignment to a single student ──────── */
function createDirectAssignment(req, res) {
const { deadline, student_email, student_id, file_id, is_homework = 1 } = req.body;
const mode = req.body.mode || 'exam';
const count = Number(req.body.count) || 25;
let { title, subject_slug, test_id } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!VALID_ASSIGN_MODES.has(mode)) return res.status(400).json({ error: 'mode must be exam, practice, repeat or ct' });
if (!Number.isInteger(count) || count < 1 || count > 200)
return res.status(400).json({ error: 'count must be an integer between 1 and 200' });
if (deadline && isNaN(Date.parse(deadline)))
return res.status(400).json({ error: 'deadline must be a valid date' });
let student;
if (student_id) {
student = db.prepare("SELECT id, name FROM users WHERE id = ? AND role = 'student'").get(Number(student_id));
if (!student) return res.status(404).json({ error: 'Ученик не найден' });
} else {
if (!student_email?.trim()) return res.status(400).json({ error: 'student_email required' });
student = db.prepare("SELECT id, name FROM users WHERE email = ? AND role = 'student'")
.get(student_email.trim().toLowerCase());
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
}
// Учитель может выдать личное задание только ученику из своего класса
if (req.user.role === 'teacher') {
const inClass = db.prepare(`
SELECT 1 FROM class_members cm
JOIN classes c ON c.id = cm.class_id
WHERE cm.user_id = ? AND c.teacher_id = ?
`).get(student.id, req.user.id);
if (!inClass) return res.status(403).json({ error: 'Ученик не входит ни в один из ваших классов' });
}
test_id = test_id ? Number(test_id) : null;
if (test_id) {
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (file_id && !subject_slug) {
const f = db.prepare('SELECT subject_slug FROM files WHERE id = ?').get(file_id);
if (f?.subject_slug) subject_slug = f.subject_slug;
}
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
if (!subject_slug) subject_slug = 'other';
const r = db.prepare(`
INSERT INTO assignments (user_id, title, subject_slug, mode, count, deadline, created_by, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(student.id, stripTags(title.trim()), subject_slug, mode, Number(count), deadline || null, req.user.id, test_id, file_id || null, is_homework ? 1 : 0);
// Уведомление ученику
pushNotif(student.id, 'assignment', `Для вас задание: «${title.trim()}»`, '/dashboard');
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/assignments/:id/sessions/:session_id/review ── teacher view ── */
function assignmentSessionReview(req, res) {
const assignmentId = Number(req.params.id);
const sessionId = Number(req.params.session_id);
// Verify assignment ownership
const a = db.prepare(`
SELECT a.id, COALESCE(c.teacher_id, a.created_by) AS teacher_id
FROM assignments a LEFT JOIN classes c ON c.id = a.class_id WHERE a.id = ?
`).get(assignmentId);
if (!a) return res.status(404).json({ error: 'Assignment not found' });
if (req.user.role !== 'admin' && a.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
// Verify session is linked to this assignment
const link = db.prepare(
'SELECT 1 FROM assignment_sessions WHERE assignment_id = ? AND session_id = ?'
).get(assignmentId, sessionId);
if (!link) return res.status(404).json({ error: 'Session not linked to this assignment' });
const session = db.prepare('SELECT score, total, status FROM test_sessions WHERE id = ?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Session not found' });
// Build per-question review — batched
const questionIds = db.prepare(
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
).all(sessionId).map(r => r.question_id);
if (!questionIds.length) return res.json({ session_id: sessionId, score: session.score, total: session.total, review: [] });
const qPh = questionIds.map(() => '?').join(',');
const questions = db.prepare(`SELECT id, text, type, explanation, correct_text FROM questions WHERE id IN (${qPh})`).all(...questionIds);
const qMap = {};
for (const q of questions) qMap[q.id] = q;
const allOptions = db.prepare(`SELECT question_id, id, text, is_correct, match_pair FROM options WHERE question_id IN (${qPh}) ORDER BY order_index`).all(...questionIds);
const optMap = {};
for (const o of allOptions) { if (!optMap[o.question_id]) optMap[o.question_id] = []; optMap[o.question_id].push(o); }
const allAnswers = db.prepare(`SELECT question_id, chosen_option_id, answer_text, is_correct FROM user_answers WHERE session_id = ? AND question_id IN (${qPh})`).all(sessionId, ...questionIds);
const ansMap = {};
for (const a of allAnswers) ansMap[a.question_id] = a;
const review = questionIds.map(qid => {
const q = qMap[qid];
if (!q) return null;
q.options = optMap[qid] || [];
const ua = ansMap[qid];
q.chosen_option_id = ua?.chosen_option_id ?? null;
q.answer_text = ua?.answer_text ?? null;
q.is_correct = ua ? ua.is_correct === 1 : null;
return q;
}).filter(Boolean);
res.json({ session_id: sessionId, score: session.score, total: session.total, review });
}
/* ── GET /api/assignments/templates ── list my templates ───────────────── */
function listTemplates(req, res) {
const rows = db.prepare(`
SELECT id, label, subject_slug, mode, count, topic_id, test_id, file_id, is_homework, created_at
FROM assignment_templates WHERE created_by = ? ORDER BY created_at DESC
`).all(req.user.id);
res.json(rows);
}
/* ── POST /api/assignments/templates ── save template ──────────────────── */
function saveTemplate(req, res) {
const { label, subject_slug, mode = 'exam', count = 25, topic_id, test_id, file_id, is_homework = 0 } = req.body;
if (!label?.trim()) return res.status(400).json({ error: 'label required' });
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
const r = db.prepare(`
INSERT INTO assignment_templates (created_by, label, subject_slug, mode, count, topic_id, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(req.user.id, label.trim(), subject_slug, mode, Number(count), topic_id || null, test_id || null, file_id || null, is_homework ? 1 : 0);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── DELETE /api/assignments/templates/:id ─────────────────────────────── */
function deleteTemplate(req, res) {
db.prepare('DELETE FROM assignment_templates WHERE id = ? AND created_by = ?')
.run(req.params.id, req.user.id);
res.json({ ok: true });
}
/* ── POST /api/assignments/bulk ── assign to multiple classes at once ───── */
function bulkCreateAssignment(req, res) {
const { class_ids, title, mode = 'exam', count = 25, topic_id, deadline, test_id, file_id, is_homework = 0 } = req.body;
let { subject_slug } = req.body;
if (!Array.isArray(class_ids) || !class_ids.length)
return res.status(400).json({ error: 'class_ids[] required' });
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!VALID_ASSIGN_MODES.has(mode)) return res.status(400).json({ error: 'invalid mode' });
if (test_id) {
const t = db.prepare('SELECT subject_slug FROM tests WHERE id = ?').get(test_id);
if (!t) return res.status(400).json({ error: 'Test not found' });
subject_slug = t.subject_slug;
}
if (!subject_slug && !is_homework) return res.status(400).json({ error: 'subject_slug required' });
if (!subject_slug) subject_slug = 'other';
const created = db.transaction(() => {
const ids = [];
for (const class_id of class_ids) {
const cls = db.prepare('SELECT id, teacher_id FROM classes WHERE id = ?').get(class_id);
if (!cls) continue;
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id) continue;
const r = db.prepare(`
INSERT INTO assignments (class_id, title, subject_slug, mode, count, topic_id, deadline, created_by, test_id, file_id, is_homework)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(cls.id, stripTags(title.trim()), subject_slug, mode, Number(count), topic_id || null, deadline || null, req.user.id, test_id || null, file_id || null, is_homework ? 1 : 0);
ids.push(r.lastInsertRowid);
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(cls.id);
members.forEach(m => pushNotif(m.user_id, 'assignment', `Новое задание: «${title.trim()}»`, '/dashboard'));
}
return ids;
})();
res.status(201).json({ created, count: created.length });
}
module.exports = {
VALID_ASSIGN_MODES,
createAssignment,
updateAssignment,
deleteAssignment,
teacherAssignments,
myAssignments,
startAssignment,
assignmentResults,
assignmentQuestionStats,
createDirectAssignment,
assignmentSessionReview,
listTemplates,
saveTemplate,
deleteTemplate,
bulkCreateAssignment,
};
+92
View File
@@ -0,0 +1,92 @@
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const db = require('../db/db');
const { BCRYPT_ROUNDS } = require('../config');
const { stripTags } = require('../utils/sanitize');
function signToken(user) {
return jwt.sign(
{ id: user.id, email: user.email, role: user.role, name: user.name, tv: user.token_version || 0 },
process.env.JWT_SECRET,
{ algorithm: 'HS256', expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
}
async function register(req, res, next) {
try {
const { password, name } = req.body;
const email = req.body.email?.trim().toLowerCase();
if (!email || !password || !name)
return res.status(400).json({ error: 'email, password and name are required' });
if (password.length < 6)
return res.status(400).json({ error: 'Password must be at least 6 characters' });
if (db.prepare('SELECT id FROM users WHERE email = ?').get(email))
return res.status(409).json({ error: 'Email already registered' });
const cleanName = stripTags(name.trim());
const hash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const { lastInsertRowid } = db.prepare(
'INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)'
).run(email, hash, cleanName);
const user = db.prepare('SELECT id, email, name, role, token_version FROM users WHERE id = ?').get(lastInsertRowid);
const token = signToken(user);
res.status(201).json({ token, user });
} catch (err) { next(err); }
}
async function login(req, res, next) {
try {
const { password } = req.body;
const email = req.body.email?.trim().toLowerCase();
if (!email || !password)
return res.status(400).json({ error: 'email and password are required' });
const user = db.prepare(
'SELECT id, email, name, role, password_hash, token_version FROM users WHERE email = ?'
).get(email);
if (!user || !(await bcrypt.compare(password, user.password_hash)))
return res.status(401).json({ error: 'Invalid credentials' });
db.prepare("UPDATE users SET last_login = datetime('now') WHERE id = ?").run(user.id);
const token = signToken(user);
res.json({ token, user: { id: user.id, email: user.email, name: user.name, role: user.role } });
} catch (err) { next(err); }
}
function me(req, res) {
const user = db.prepare(
'SELECT id, email, name, role, created_at, last_login FROM users WHERE id = ?'
).get(req.user.id);
res.json(user);
}
async function updateProfile(req, res, next) {
try {
const { name, currentPassword, newPassword } = req.body;
const user = db.prepare('SELECT id, name, email, role, password_hash FROM users WHERE id = ?').get(req.user.id);
if (name?.trim() && name.trim() !== user.name) {
db.prepare('UPDATE users SET name = ? WHERE id = ?').run(stripTags(name.trim()), user.id);
}
if (newPassword) {
if (!currentPassword) return res.status(400).json({ error: 'Текущий пароль обязателен' });
const valid = await bcrypt.compare(currentPassword, user.password_hash);
if (!valid) return res.status(401).json({ error: 'Неверный текущий пароль' });
if (newPassword.length < 6) return res.status(400).json({ error: 'Пароль минимум 6 символов' });
const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
db.prepare('UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?').run(hash, user.id);
}
const updated = db.prepare('SELECT id, email, name, role, created_at, token_version FROM users WHERE id = ?').get(user.id);
const token = signToken(updated);
res.json({ user: updated, token });
} catch (err) { next(err); }
}
module.exports = { register, login, me, updateProfile };
@@ -0,0 +1,276 @@
'use strict';
const db = require('../db/db');
const { awardXP } = require('./gamificationController');
/* ── Helpers ─────────────────────────────────────────────────────────── */
const MAX_V = { H:1, C:4, N:3, O:2, P:5, S:6, Cl:1, Na:1, Ca:2, K:1, Mg:2, Fe:3, Br:1, I:1, F:1 };
function hillFormula(atoms) {
const cnt = {};
for (const a of atoms) cnt[a.s] = (cnt[a.s] || 0) + 1;
const parts = [];
if (cnt.C) { parts.push('C' + (cnt.C > 1 ? cnt.C : '')); delete cnt.C; }
if (cnt.H) { parts.push('H' + (cnt.H > 1 ? cnt.H : '')); delete cnt.H; }
for (const el of Object.keys(cnt).sort()) parts.push(el + (cnt[el] > 1 ? cnt[el] : ''));
return parts.join('');
}
function valencyIssues(atoms, bonds) {
const sums = {};
for (const b of bonds) {
sums[b.f] = (sums[b.f] || 0) + b.o;
sums[b.t] = (sums[b.t] || 0) + b.o;
}
return atoms
.filter(a => (sums[a.id] || 0) > (MAX_V[a.s] ?? 4))
.map(a => ({ id: a.id, symbol: a.s, used: sums[a.id] || 0, max: MAX_V[a.s] ?? 4 }));
}
/* ── Prepared statements ─────────────────────────────────────────────── */
const stmts = {
getElements: db.prepare('SELECT * FROM bio_elements ORDER BY radius ASC'),
getMolecules: db.prepare("SELECT id,formula,name_ru,name_lat,category,difficulty,description,topic_tags,atoms_json,bonds_json FROM bio_molecules WHERE is_library=1 ORDER BY difficulty,name_ru"),
getMolCat: db.prepare("SELECT id,formula,name_ru,name_lat,category,difficulty,description,topic_tags,atoms_json,bonds_json FROM bio_molecules WHERE is_library=1 AND category=? ORDER BY difficulty,name_ru"),
getMolSearch: db.prepare("SELECT id,formula,name_ru,name_lat,category,difficulty,description,topic_tags,atoms_json,bonds_json FROM bio_molecules WHERE is_library=1 AND (name_ru LIKE ? OR formula LIKE ?) ORDER BY difficulty,name_ru"),
getMolById: db.prepare('SELECT * FROM bio_molecules WHERE id=?'),
getMolByFormula:db.prepare('SELECT id,name_ru,name_lat,category,description,topic_tags FROM bio_molecules WHERE formula=? LIMIT 1'),
getReactions: db.prepare('SELECT * FROM bio_reactions ORDER BY name_ru'),
getChallenges: db.prepare('SELECT * FROM bio_challenges ORDER BY difficulty,order_n'),
getChallenge: db.prepare('SELECT * FROM bio_challenges WHERE id=?'),
checkDone: db.prepare('SELECT 1 FROM bio_user_challenges WHERE user_id=? AND challenge_id=?'),
markDone: db.prepare('INSERT OR IGNORE INTO bio_user_challenges (user_id,challenge_id) VALUES (?,?)'),
getDoneIds: db.prepare('SELECT challenge_id FROM bio_user_challenges WHERE user_id=?'),
getSaved: db.prepare('SELECT bm.*, bmo.name_ru AS mol_name FROM bio_user_molecules bm LEFT JOIN bio_molecules bmo ON bmo.id=bm.molecule_id WHERE bm.user_id=? ORDER BY bm.created_at DESC'),
saveMol: db.prepare('INSERT INTO bio_user_molecules (user_id,molecule_id,name,formula,atoms_json,bonds_json) VALUES (?,?,?,?,?,?)'),
deleteSaved: db.prepare('DELETE FROM bio_user_molecules WHERE id=? AND user_id=?'),
};
/* ── GET /api/biochem/elements ───────────────────────────────────────── */
function getElements(_req, res) {
res.json(stmts.getElements.all());
}
/* ── GET /api/biochem/molecules ──────────────────────────────────────── */
function getMolecules(req, res) {
const { cat, q } = req.query;
let rows;
if (q) {
const like = `%${q}%`;
rows = stmts.getMolSearch.all(like, like);
} else if (cat) {
rows = stmts.getMolCat.all(cat);
} else {
rows = stmts.getMolecules.all();
}
rows = rows.map(r => ({
...r,
topic_tags: tryParse(r.topic_tags, []),
atoms_json: tryParse(r.atoms_json, []),
bonds_json: tryParse(r.bonds_json, []),
}));
res.json(rows);
}
/* ── GET /api/biochem/molecules/:id ─────────────────────────────────── */
function getMolecule(req, res) {
const mol = stmts.getMolById.get(req.params.id);
if (!mol) return res.status(404).json({ error: 'Not found' });
res.json({
...mol,
atoms_json: tryParse(mol.atoms_json, []),
bonds_json: tryParse(mol.bonds_json, []),
topic_tags: tryParse(mol.topic_tags, []),
});
}
/* ── POST /api/biochem/validate ─────────────────────────────────────── */
function validate(req, res) {
const { atoms, bonds } = req.body;
if (!Array.isArray(atoms) || !Array.isArray(bonds))
return res.status(400).json({ error: 'atoms[] and bonds[] required' });
if (atoms.length === 0) return res.json({ valid: false, formula: '', issues: [] });
const formula = hillFormula(atoms);
const issues = valencyIssues(atoms, bonds);
const valid = issues.length === 0;
const known = valid ? stmts.getMolByFormula.get(formula) : null;
res.json({ valid, formula, issues, known: known || null });
}
/* ── GET /api/biochem/reactions ─────────────────────────────────────── */
function getReactions(_req, res) {
const rows = stmts.getReactions.all().map(r => ({
...r,
reactant_ids: tryParse(r.reactant_ids, []),
product_ids: tryParse(r.product_ids, []),
topic_tags: tryParse(r.topic_tags, []),
}));
res.json(rows);
}
/* ── GET /api/biochem/challenges ────────────────────────────────────── */
function getChallenges(req, res) {
const challenges = stmts.getChallenges.all();
const doneSet = new Set(stmts.getDoneIds.all(req.user.id).map(r => r.challenge_id));
res.json(challenges.map(c => ({
...c,
data_json: tryParse(c.data_json, null),
done: doneSet.has(c.id),
})));
}
/* ── POST /api/biochem/challenges/:id/solve ─────────────────────────── */
function solveChallenge(req, res) {
const challenge = stmts.getChallenge.get(req.params.id);
if (!challenge) return res.status(404).json({ error: 'Challenge not found' });
if (stmts.checkDone.get(req.user.id, challenge.id))
return res.status(400).json({ error: 'already_completed' });
const type = challenge.type || 'build';
/* ── identify: shown structure → pick name ── */
if (type === 'identify') {
const { answer } = req.body;
if (typeof answer !== 'string' || !answer)
return res.status(400).json({ error: 'answer required' });
const mol = stmts.getMolByFormula.get(challenge.target_formula);
if (!mol || answer !== mol.name_ru)
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── formula: shown name → pick formula ── */
if (type === 'formula') {
const { answer } = req.body;
if (typeof answer !== 'string' || !answer)
return res.status(400).json({ error: 'answer required' });
if (answer !== challenge.target_formula)
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── classify: shown molecule → pick class ── */
if (type === 'classify') {
const { answer } = req.body;
if (typeof answer !== 'string' || !answer)
return res.status(400).json({ error: 'answer required' });
const data = tryParse(challenge.data_json, {});
if (answer !== data.answer)
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── complete: shown partial reaction → pick missing component ── */
if (type === 'complete') {
const { answer } = req.body;
if (typeof answer !== 'string' || !answer)
return res.status(400).json({ error: 'answer required' });
const data = tryParse(challenge.data_json, {});
if (answer !== data.answer)
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── balance: fill coefficients to balance equation ── */
if (type === 'balance') {
const { coefficients } = req.body;
const data = tryParse(challenge.data_json, {});
const expected = data.coefficients || [];
if (!Array.isArray(coefficients) ||
coefficients.length !== expected.length ||
!coefficients.every((c, i) => parseInt(c) === expected[i]))
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── match: pair left items with right items ── */
if (type === 'match') {
const { pairs } = req.body;
const data = tryParse(challenge.data_json, {});
const answerMap = new Map((data.pairs || []).map(p => [p.left, p.right]));
if (!Array.isArray(pairs) ||
pairs.length !== answerMap.size ||
!pairs.every(p => answerMap.get(p.left) === p.right))
return res.status(400).json({ error: 'wrong_answer' });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
return res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── build (default): draw the molecule ── */
const { atoms, bonds } = req.body;
if (!Array.isArray(atoms) || !Array.isArray(bonds))
return res.status(400).json({ error: 'atoms[] and bonds[] required' });
const formula = hillFormula(atoms);
if (formula !== challenge.target_formula)
return res.status(400).json({ error: 'wrong_formula', submitted: formula, expected: challenge.target_formula });
const issues = valencyIssues(atoms, bonds);
if (issues.length > 0)
return res.status(400).json({ error: 'valency_error', issues });
stmts.markDone.run(req.user.id, challenge.id);
awardXP(req.user.id, challenge.xp_reward, `biochem_challenge:${challenge.id}`);
res.json({ ok: true, xp: challenge.xp_reward });
}
/* ── GET /api/biochem/saved ─────────────────────────────────────────── */
function getSaved(req, res) {
const rows = stmts.getSaved.all(req.user.id).map(r => ({
...r,
atoms_json: tryParse(r.atoms_json, []),
bonds_json: tryParse(r.bonds_json, []),
}));
res.json(rows);
}
/* ── POST /api/biochem/saved ────────────────────────────────────────── */
function saveMolecule(req, res) {
const { atoms, bonds, name } = req.body;
if (!Array.isArray(atoms) || !Array.isArray(bonds) || atoms.length === 0)
return res.status(400).json({ error: 'atoms[] and bonds[] required' });
const formula = hillFormula(atoms);
const known = stmts.getMolByFormula.get(formula);
const id = stmts.saveMol.run(
req.user.id,
known?.id ?? null,
name?.trim() || null,
formula,
JSON.stringify(atoms),
JSON.stringify(bonds),
).lastInsertRowid;
res.status(201).json({ id, formula, known: known || null });
}
/* ── DELETE /api/biochem/saved/:id ──────────────────────────────────── */
function deleteSaved(req, res) {
const info = stmts.deleteSaved.run(req.params.id, req.user.id);
if (info.changes === 0) return res.status(404).json({ error: 'Not found' });
res.json({ ok: true });
}
/* ── util ────────────────────────────────────────────────────────────── */
function tryParse(v, fallback) {
if (!v) return fallback;
try { return JSON.parse(v); } catch { return fallback; }
}
module.exports = {
getElements, getMolecules, getMolecule, validate,
getReactions, getChallenges, solveChallenge,
getSaved, saveMolecule, deleteSaved,
};
@@ -0,0 +1,103 @@
const db = require('../db/db');
const VALID_TYPES = ['lesson', 'course', 'file', 'question'];
/* ── GET /api/bookmarks?type=lesson ── list user bookmarks ─────────── */
function list(req, res) {
const uid = req.user.id;
const { type } = req.query;
let where = 'WHERE b.user_id = ?';
const args = [uid];
if (type && VALID_TYPES.includes(type)) {
where += ' AND b.entity_type = ?';
args.push(type);
}
const rows = db.prepare(`
SELECT b.id, b.entity_type, b.entity_id, b.created_at
FROM bookmarks b ${where}
ORDER BY b.created_at DESC
`).all(...args);
// Batch-fetch titles by type to avoid N+1
const byType = {};
for (const r of rows) (byType[r.entity_type] ||= []).push(r);
const titleMap = new Map();
if (byType.lesson?.length) {
const ids = byType.lesson.map(r => r.entity_id);
const ph = ids.map(() => '?').join(',');
for (const l of db.prepare(`SELECT l.id, l.title, c.title AS course_title, c.id AS course_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id IN (${ph})`).all(...ids))
titleMap.set(`lesson:${l.id}`, { title: l.title, courseTitle: l.course_title, courseId: l.course_id });
}
if (byType.course?.length) {
const ids = byType.course.map(r => r.entity_id);
const ph = ids.map(() => '?').join(',');
for (const c of db.prepare(`SELECT id, title, subject_slug, cover_emoji FROM courses WHERE id IN (${ph})`).all(...ids))
titleMap.set(`course:${c.id}`, { title: c.title, subjectSlug: c.subject_slug, coverEmoji: c.cover_emoji });
}
if (byType.file?.length) {
const ids = byType.file.map(r => r.entity_id);
const ph = ids.map(() => '?').join(',');
for (const f of db.prepare(`SELECT id, title, original_name FROM files WHERE id IN (${ph})`).all(...ids))
titleMap.set(`file:${f.id}`, { title: f.title || f.original_name });
}
if (byType.question?.length) {
const ids = byType.question.map(r => r.entity_id);
const ph = ids.map(() => '?').join(',');
for (const q of db.prepare(`SELECT id, text FROM questions WHERE id IN (${ph})`).all(...ids))
titleMap.set(`question:${q.id}`, { title: q.text.slice(0, 100) });
}
const enriched = rows.map(r => {
const info = titleMap.get(`${r.entity_type}:${r.entity_id}`);
if (!info) return null;
const { title, ...extra } = info;
return { ...r, title, ...extra };
}).filter(Boolean);
res.json(enriched);
}
/* ── POST /api/bookmarks ── add bookmark ───────────────────────────── */
function add(req, res) {
const { entityType, entityId } = req.body;
if (!entityType || !entityId) return res.status(400).json({ error: 'entityType and entityId required' });
if (!VALID_TYPES.includes(entityType)) return res.status(400).json({ error: 'Invalid entity type' });
try {
const r = db.prepare(
'INSERT INTO bookmarks (user_id, entity_type, entity_id) VALUES (?, ?, ?)'
).run(req.user.id, entityType, entityId);
res.status(201).json({ id: Number(r.lastInsertRowid) });
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Already bookmarked' });
throw e;
}
}
/* ── DELETE /api/bookmarks/:id ── remove bookmark ──────────────────── */
function remove(req, res) {
const bm = db.prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id);
if (!bm) return res.status(404).json({ error: 'Bookmark not found' });
db.prepare('DELETE FROM bookmarks WHERE id = ?').run(bm.id);
res.json({ ok: true });
}
/* ── DELETE /api/bookmarks/entity/:type/:entityId ── remove by entity ── */
function removeByEntity(req, res) {
const { type, entityId } = req.params;
db.prepare('DELETE FROM bookmarks WHERE user_id = ? AND entity_type = ? AND entity_id = ?').run(req.user.id, type, entityId);
res.json({ ok: true });
}
/* ── GET /api/bookmarks/check/:type/:entityId ── check if bookmarked ── */
function check(req, res) {
const { type, entityId } = req.params;
const row = db.prepare('SELECT id FROM bookmarks WHERE user_id = ? AND entity_type = ? AND entity_id = ?').get(req.user.id, type, entityId);
res.json({ bookmarked: !!row, id: row?.id || null });
}
module.exports = { list, add, remove, removeByEntity, check };
+559
View File
@@ -0,0 +1,559 @@
const db = require('../db/db');
const crypto = require('crypto');
const { onClassJoined } = require('./gamificationController');
const { pushNotif } = require('../utils/notifications');
const { stripTags } = require('../utils/sanitize');
function genCode() {
return crypto.randomBytes(4).toString('hex').toUpperCase();
}
/* ── Prepared statements (module-level to avoid re-parsing per request) ── */
const stmts = {
getClassOwner: db.prepare('SELECT id, teacher_id FROM classes WHERE id = ?'),
getClassWithName: db.prepare('SELECT id, name, teacher_id FROM classes WHERE id = ?'),
getClassByCode: db.prepare('SELECT id, name FROM classes WHERE invite_code = ?'),
checkCodeExists: db.prepare('SELECT id FROM classes WHERE invite_code = ?'),
getMemberCheck: db.prepare('SELECT 1 FROM class_members WHERE class_id = ? AND user_id = ?'),
getClassMembers: db.prepare('SELECT user_id FROM class_members WHERE class_id = ?'),
insertMember: db.prepare('INSERT INTO class_members (class_id, user_id) VALUES (?, ?)'),
deleteMember: db.prepare('DELETE FROM class_members WHERE class_id = ? AND user_id = ?'),
deleteClass: db.prepare('DELETE FROM classes WHERE id = ?'),
updateClassCode: db.prepare('UPDATE classes SET invite_code = ? WHERE id = ?'),
updateClassName: db.prepare('UPDATE classes SET name = ? WHERE id = ?'),
updateClassDesc: db.prepare('UPDATE classes SET description = ? WHERE id = ?'),
updateClassFeats: db.prepare('UPDATE classes SET features = ? WHERE id = ?'),
getClassUpdated: db.prepare('SELECT id, name, description, invite_code, features, cover_emoji FROM classes WHERE id = ?'),
getClassTeacherId: db.prepare('SELECT teacher_id FROM classes WHERE id = ?'),
getStudentById: db.prepare("SELECT id, name FROM users WHERE id = ? AND role = 'student'"),
getStudentByEmail: db.prepare("SELECT id, name FROM users WHERE email = ? AND role = 'student'"),
insertAnnouncement: db.prepare('INSERT INTO announcements (class_id, author_id, text) VALUES (?, ?, ?)'),
deleteAnnouncement: db.prepare('DELETE FROM announcements WHERE id = ? AND class_id = ?'),
};
/* ── GET /api/classes ── teacher: own classes; admin: all ─────────────── */
function listClasses(req, res) {
const { role, id: uid } = req.user;
const rows = role === 'admin'
? db.prepare(`
SELECT c.id, c.name, c.description, c.invite_code, c.cover_emoji, c.created_at,
u.name AS teacher_name,
COUNT(DISTINCT cm.user_id) AS member_count,
COUNT(DISTINCT a.id) AS assignment_count
FROM classes c
JOIN users u ON u.id = c.teacher_id
LEFT JOIN class_members cm ON cm.class_id = c.id
LEFT JOIN assignments a ON a.class_id = c.id
GROUP BY c.id ORDER BY c.created_at DESC
LIMIT 500
`).all()
: db.prepare(`
SELECT c.id, c.name, c.description, c.invite_code, c.cover_emoji, c.created_at,
COUNT(DISTINCT cm.user_id) AS member_count,
COUNT(DISTINCT a.id) AS assignment_count
FROM classes c
LEFT JOIN class_members cm ON cm.class_id = c.id
LEFT JOIN assignments a ON a.class_id = c.id
WHERE c.teacher_id = ?
GROUP BY c.id ORDER BY c.created_at DESC
`).all(uid);
res.json(rows);
}
/* ── POST /api/classes ── create ───────────────────────────────────────── */
function createClass(req, res) {
const { name, description, cover_emoji } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name required' });
let invite_code, attempts = 0;
while (attempts++ < 10) {
invite_code = genCode();
if (!stmts.checkCodeExists.get(invite_code)) break;
invite_code = null;
}
if (!invite_code) return res.status(500).json({ error: 'Не удалось сгенерировать уникальный код — попробуйте ещё раз' });
const cleanName = stripTags(name.trim());
const cleanDesc = description ? stripTags(description.trim()) : null;
const emoji = cover_emoji ? stripTags(String(cover_emoji).trim()).slice(0, 50) : '';
const r = db.prepare(
'INSERT INTO classes (name, description, teacher_id, invite_code, cover_emoji) VALUES (?, ?, ?, ?, ?)'
).run(cleanName, cleanDesc, req.user.id, invite_code, emoji);
res.status(201).json({ id: r.lastInsertRowid, name: cleanName, invite_code, cover_emoji: emoji });
}
/* ── GET /api/classes/:id ── detail (teacher/admin) ────────────────────── */
function getClass(req, res) {
const cls = db.prepare(`
SELECT c.*, u.name AS teacher_name
FROM classes c JOIN users u ON u.id = c.teacher_id
WHERE c.id = ?
`).get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Class not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
if (cls.features) {
try { cls.features = JSON.parse(cls.features); } catch { cls.features = null; }
}
const members = db.prepare(`
SELECT u.id, u.name, u.email, cm.joined_at
FROM class_members cm
JOIN users u ON u.id = cm.user_id
WHERE cm.class_id = ?
ORDER BY cm.joined_at DESC
`).all(req.params.id);
// Load per-member stats in one query instead of N+1 LEFT JOIN
if (members.length > 0) {
const uIds = members.map(m => m.id);
const ph = uIds.map(() => '?').join(',');
const stats = db.prepare(`
SELECT user_id,
COUNT(*) AS tests_count,
ROUND(AVG(CAST(score AS REAL) / total * 100), 1) AS avg_pct
FROM test_sessions
WHERE user_id IN (${ph}) AND status = 'completed'
GROUP BY user_id
`).all(...uIds);
const statsMap = {};
for (const s of stats) statsMap[s.user_id] = s;
for (const m of members) {
m.tests_count = statsMap[m.id]?.tests_count || 0;
m.avg_pct = statsMap[m.id]?.avg_pct || 0;
}
}
const assignments = db.prepare(`
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
a.file_id, f.title AS file_title, a.test_id, a.max_attempts,
(SELECT COUNT(*) FROM class_members WHERE class_id = a.class_id) AS total_members,
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS completed_count
FROM assignments a
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN assignment_sessions ases ON ases.assignment_id = a.id
LEFT JOIN test_sessions ts ON ts.id = ases.session_id
WHERE a.class_id = ?
GROUP BY a.id ORDER BY a.created_at DESC
`).all(req.params.id);
res.json({ ...cls, members, assignments });
}
/* ── PATCH /api/classes/:id ── rename / update description ─────────────── */
function updateClass(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { name, description, features, cover_emoji } = req.body;
if (name?.trim()) stmts.updateClassName.run(stripTags(name.trim()), cls.id);
if (description !== undefined) stmts.updateClassDesc.run(description?.trim() || null, cls.id);
if (cover_emoji !== undefined) {
const emoji = cover_emoji ? stripTags(String(cover_emoji).trim()).slice(0, 50) : '';
db.prepare('UPDATE classes SET cover_emoji = ? WHERE id = ?').run(emoji, cls.id);
}
if (features !== undefined) stmts.updateClassFeats.run(features !== null ? JSON.stringify(features) : null, cls.id);
const updated = stmts.getClassUpdated.get(cls.id);
if (updated.features) {
try { updated.features = JSON.parse(updated.features); } catch { updated.features = null; }
}
res.json(updated);
}
/* ── POST /api/classes/:id/new-code ── regenerate invite code ───────────── */
function regenerateCode(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
let code, attempts = 0;
while (attempts++ < 10) {
code = genCode();
if (!stmts.checkCodeExists.get(code)) break;
code = null;
}
if (!code) return res.status(500).json({ error: 'Не удалось сгенерировать уникальный код — попробуйте ещё раз' });
stmts.updateClassCode.run(code, cls.id);
res.json({ invite_code: code });
}
/* ── GET /api/classes/:id/journal ── grade matrix for teacher ────────────── */
function classJournal(req, res) {
const cls = stmts.getClassWithName.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const members = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
`).all(req.params.id);
const assignments = db.prepare(`
SELECT id, title, subject_slug, deadline, is_homework, created_at
FROM assignments WHERE class_id = ? AND user_id IS NULL ORDER BY created_at ASC
`).all(req.params.id);
const results = db.prepare(`
SELECT ases.user_id, ases.assignment_id,
MAX(ts.score) AS score,
ts.total,
MAX(ROUND(CAST(ts.score AS REAL) / ts.total * 100)) AS percent,
MAX(ts.finished_at) AS finished_at
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id AND ts.status = 'completed'
JOIN assignments a ON a.id = ases.assignment_id
WHERE a.class_id = ? AND a.user_id IS NULL
GROUP BY ases.user_id, ases.assignment_id
`).all(req.params.id);
// course progress per student
const courses = db.prepare(`
SELECT c.id, c.title, c.subject_slug,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id AND l.is_published = 1) AS lesson_count
FROM class_courses cc
JOIN courses c ON cc.course_id = c.id
WHERE cc.class_id = ?
ORDER BY cc.assigned_at
`).all(req.params.id);
let courseProgress = [];
if (courses.length && members.length) {
const cIds = courses.map(c => c.id);
const mIds = members.map(m => m.id);
const cPh = cIds.map(() => '?').join(',');
const mPh = mIds.map(() => '?').join(',');
const rows = db.prepare(`
SELECT l.course_id, lp.user_id, COUNT(*) AS done_count
FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
WHERE l.course_id IN (${cPh}) AND lp.user_id IN (${mPh}) AND lp.completed = 1
GROUP BY l.course_id, lp.user_id
`).all(...cIds, ...mIds);
const doneMap = {};
for (const r of rows) doneMap[r.user_id + '_' + r.course_id] = r.done_count;
for (const c of courses) {
for (const m of members) {
const done = doneMap[m.id + '_' + c.id] || 0;
courseProgress.push({
userId: m.id, courseId: c.id,
doneCount: done, totalLessons: c.lesson_count,
percent: c.lesson_count > 0 ? Math.round(done / c.lesson_count * 100) : 0,
});
}
}
}
// averages per student — O(n) via Map instead of O(n*m) filter
const resultsByUser = new Map();
for (const r of results) {
if (!resultsByUser.has(r.user_id)) resultsByUser.set(r.user_id, []);
resultsByUser.get(r.user_id).push(r);
}
const studentStats = members.map(m => {
const studentResults = resultsByUser.get(m.id) || [];
const avgPct = studentResults.length > 0
? Math.round(studentResults.reduce((s, r) => s + r.percent, 0) / studentResults.length)
: null;
return { userId: m.id, avgPct, completedCount: studentResults.length, totalAssignments: assignments.length };
});
res.json({ className: cls.name, members, assignments, results, courses, courseProgress, studentStats });
}
/* ── GET /api/classes/:id/journal/csv ── export gradebook as CSV ────────── */
function classJournalCsv(req, res) {
const cls = stmts.getClassWithName.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const members = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
`).all(req.params.id);
const assignments = db.prepare(`
SELECT id, title FROM assignments WHERE class_id = ? AND user_id IS NULL ORDER BY created_at ASC
`).all(req.params.id);
const results = db.prepare(`
SELECT ases.user_id, ases.assignment_id,
MAX(ROUND(CAST(ts.score AS REAL) / ts.total * 100)) AS percent
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id AND ts.status = 'completed'
JOIN assignments a ON a.id = ases.assignment_id
WHERE a.class_id = ? AND a.user_id IS NULL
GROUP BY ases.user_id, ases.assignment_id
`).all(req.params.id);
// Build result map
const rmap = {};
results.forEach(r => { rmap[r.user_id + '_' + r.assignment_id] = r.percent; });
// CSV header
// Protect against CSV formula injection (=, +, @, - at start trigger Excel/Sheets formulas)
const csvEsc = s => {
const str = String(s || '');
const safe = /^[=+@\-]/.test(str) ? `'${str}` : str;
return '"' + safe.replace(/"/g, '""') + '"';
};
const header = ['Ученик', 'Email', ...assignments.map(a => a.title), 'Средний %'];
const rows = [header.map(csvEsc).join(',')];
members.forEach(m => {
const scores = assignments.map(a => {
const key = m.id + '_' + a.id;
return rmap[key] !== undefined ? rmap[key] : '';
});
const filled = scores.filter(s => s !== '');
const avg = filled.length > 0 ? Math.round(filled.reduce((s, v) => s + v, 0) / filled.length) : '';
rows.push([csvEsc(m.name), csvEsc(m.email), ...scores, avg].join(','));
});
const bom = '\uFEFF'; // UTF-8 BOM for Excel
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="gradebook-${cls.name.replace(/[^a-zA-Zа-яА-Я0-9]/g, '_')}.csv"`);
res.send(bom + rows.join('\n'));
}
/* ── DELETE /api/classes/:id ──────────────────────────────────────────── */
function deleteClass(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
stmts.deleteClass.run(req.params.id);
res.json({ ok: true });
}
/* ── POST /api/classes/join ── student joins by invite code ────────────── */
function joinClass(req, res) {
const { invite_code } = req.body;
const cls = stmts.getClassByCode.get(invite_code?.trim().toUpperCase());
if (!cls) return res.status(404).json({ error: 'Неверный код приглашения' });
try {
stmts.insertMember.run(cls.id, req.user.id);
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Вы уже в этом классе' });
throw e;
}
// Notify teacher about new student joining
try {
const teacher = stmts.getClassTeacherId.get(cls.id);
if (teacher) pushNotif(teacher.teacher_id, 'join', `«${req.user.name}» вступил в класс «${cls.name}»`, '/classes');
} catch {}
try { onClassJoined(req.user.id); } catch {}
res.json({ ok: true, class_name: cls.name });
}
/* ── GET /api/classes/students ── list students in teacher's classes ─ */
function listStudents(req, res) {
if (req.user.role === 'admin') {
const rows = db.prepare(
"SELECT id, name, email FROM users WHERE role IN ('student','free_student') ORDER BY name"
).all();
return res.json(rows);
}
// Teacher: only students in their classes
const rows = db.prepare(`
SELECT DISTINCT u.id, u.name, u.email FROM users u
JOIN class_members cm ON cm.user_id = u.id
JOIN classes c ON c.id = cm.class_id
WHERE c.teacher_id = ? AND u.role IN ('student','free_student')
ORDER BY u.name
`).all(req.user.id);
res.json(rows);
}
/* ── POST /api/classes/:id/members ── admin/teacher adds student by email or id ─ */
function addMember(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Class not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { email, user_id } = req.body;
let student;
if (user_id) {
student = stmts.getStudentById.get(Number(user_id));
if (!student) return res.status(404).json({ error: 'Ученик не найден' });
} else {
if (!email?.trim()) return res.status(400).json({ error: 'email required' });
student = stmts.getStudentByEmail.get(email.trim().toLowerCase());
if (!student) return res.status(404).json({ error: 'Ученик с таким email не найден' });
}
try {
stmts.insertMember.run(cls.id, student.id);
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Ученик уже в этом классе' });
throw e;
}
res.json({ ok: true, name: student.name });
}
/* ── DELETE /api/classes/:id/members/:uid ── kick ─────────────────────── */
function kickMember(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
stmts.deleteMember.run(req.params.id, req.params.uid);
res.json({ ok: true });
}
/* ── GET /api/classes/my ── student's enrolled classes ─────────────────── */
function myClasses(req, res) {
const classes = db.prepare(`
SELECT c.id, c.name, c.description, u.name AS teacher_name,
COUNT(DISTINCT a.id) AS assignment_count
FROM class_members cm
JOIN classes c ON c.id = cm.class_id
JOIN users u ON u.id = c.teacher_id
LEFT JOIN assignments a ON a.class_id = c.id
WHERE cm.user_id = ?
GROUP BY c.id ORDER BY cm.joined_at DESC
`).all(req.user.id);
res.json(classes);
}
/* ── GET /api/classes/:id/announcements ─────────────────────────────────── */
function getAnnouncements(req, res) {
const announcements = db.prepare(`
SELECT a.id, a.text, a.created_at, u.name AS author_name
FROM announcements a
JOIN users u ON u.id = a.author_id
WHERE a.class_id = ?
ORDER BY a.created_at DESC LIMIT 50
`).all(req.params.id);
res.json(announcements);
}
/* ── POST /api/classes/:id/announcements ────────────────────────────────── */
function createAnnouncement(req, res) {
const text = stripTags(req.body.text || '');
if (!text) return res.status(400).json({ error: 'text required' });
if (text.length > 4000) return res.status(400).json({ error: 'text too long (max 4000 chars)' });
const cls = stmts.getClassWithName.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const r = stmts.insertAnnouncement.run(req.params.id, req.user.id, text);
const members = stmts.getClassMembers.all(req.params.id);
const preview = text.slice(0, 80) + (text.length > 80 ? '…' : '');
const batchNotif = db.transaction(() => {
for (const m of members) {
pushNotif(m.user_id, 'announcement', `Объявление в «${cls.name}»: ${preview}`, '/classes');
}
});
batchNotif();
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/classes/:id/feed ── class board (students + teacher) ──────── */
function classFeed(req, res) {
const { role, id: uid } = req.user;
const classId = req.params.id;
const cls = stmts.getClassWithName.get(classId);
if (!cls) return res.status(404).json({ error: 'Not found' });
const isTeacherOrAdmin = role === 'admin' || cls.teacher_id === uid;
if (!isTeacherOrAdmin) {
const member = stmts.getMemberCheck.get(classId, uid);
if (!member) return res.status(403).json({ error: 'Forbidden' });
}
const assignments = db.prepare(`
SELECT a.id, a.title, a.subject_slug, a.mode, a.deadline, a.created_at, a.is_homework,
a.max_attempts,
f.title AS file_title,
COUNT(DISTINCT cm.user_id) AS total_members,
COUNT(DISTINCT CASE WHEN ts.status = 'completed' THEN ases.user_id END) AS done_count
FROM assignments a
LEFT JOIN files f ON f.id = a.file_id
LEFT JOIN class_members cm ON cm.class_id = a.class_id
LEFT JOIN assignment_sessions ases ON ases.assignment_id = a.id
LEFT JOIN test_sessions ts ON ts.id = ases.session_id
WHERE a.class_id = ?
GROUP BY a.id
ORDER BY a.created_at DESC LIMIT 20
`).all(classId);
if (!isTeacherOrAdmin && assignments.length) {
const aIds = assignments.map(a => a.id);
const ph = aIds.map(() => '?').join(',');
// Latest session per assignment
const latest = db.prepare(`
SELECT ases.assignment_id, ts.score, ts.total, ts.status
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id IN (${ph}) AND ases.user_id = ?
AND ases.id = (SELECT MAX(a2.id) FROM assignment_sessions a2
WHERE a2.assignment_id = ases.assignment_id AND a2.user_id = ases.user_id)
`).all(...aIds, uid);
const latestMap = {};
for (const r of latest) latestMap[r.assignment_id] = r;
// Completed attempts count per assignment
const attempts = db.prepare(`
SELECT ases.assignment_id, COUNT(*) AS n
FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id AND ts.status = 'completed'
WHERE ases.assignment_id IN (${ph}) AND ases.user_id = ?
GROUP BY ases.assignment_id
`).all(...aIds, uid);
const attemptsMap = {};
for (const r of attempts) attemptsMap[r.assignment_id] = r.n;
for (const a of assignments) {
const ses = latestMap[a.id];
a.my_status = ses ? ses.status : null;
a.my_score = ses?.score ?? null;
a.my_total = ses?.total ?? null;
a.attempts_used = attemptsMap[a.id] || 0;
}
}
const announcements = db.prepare(`
SELECT a.id, a.text, a.created_at, u.name AS author_name
FROM announcements a
JOIN users u ON u.id = a.author_id
WHERE a.class_id = ?
ORDER BY a.created_at DESC LIMIT 20
`).all(classId);
const activity = db.prepare(`
SELECT u.name AS student_name, a.title AS assignment_title,
ts.score, ts.total, ts.finished_at AS completed_at
FROM test_sessions ts
JOIN assignment_sessions ases ON ases.session_id = ts.id
JOIN assignments a ON a.id = ases.assignment_id
JOIN users u ON u.id = ts.user_id
WHERE a.class_id = ? AND ts.status = 'completed'
ORDER BY ts.finished_at DESC LIMIT 15
`).all(classId);
res.json({ class: cls, assignments, announcements, activity });
}
/* ── DELETE /api/classes/:id/announcements/:aid ─────────────────────────── */
function deleteAnnouncement(req, res) {
const cls = stmts.getClassOwner.get(req.params.id);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
stmts.deleteAnnouncement.run(req.params.aid, req.params.id);
res.json({ ok: true });
}
module.exports = {
listClasses, createClass, getClass, deleteClass,
joinClass, kickMember, addMember, myClasses, listStudents,
getAnnouncements, createAnnouncement, deleteAnnouncement, classFeed,
updateClass, regenerateCode, classJournal, classJournalCsv,
};
@@ -0,0 +1,998 @@
const db = require('../db/db');
const path = require('path');
const fs = require('fs');
const { emit, emitToClass, getOnlineUserIds } = require('../sse');
/* ── chat attachment uploads dir ─────────────────────────────────────── */
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../uploads/chat');
if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true });
/* ── Draw permissions persisted in DB ─────────────────────────────────── */
function canDraw(sessionId, userId, session) {
if (session.teacher_id === userId) return true;
return !!db.prepare(
'SELECT 1 FROM classroom_draw_permissions WHERE session_id=? AND user_id=?'
).get(sessionId, userId);
}
/* ── Helper: broadcast to all session participants ─────────────────────── */
function emitToSession(sessionId, data) {
const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return;
if (session.class_id) {
emitToClass(session.class_id, data);
emit(session.teacher_id, data); // teacher is not in class_members — emit separately
} else {
// personal session — emit to teacher + each invited user
emit(session.teacher_id, data);
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
for (const { user_id } of invites) emit(user_id, data);
}
}
/* ── Helper: check if user has access to session ──────────────────────── */
function hasAccess(session, userId, userRole) {
if (userRole === 'admin') return true;
if (session.teacher_id === userId) return true;
if (session.class_id) {
return !!db.prepare('SELECT 1 FROM class_members WHERE class_id=? AND user_id=?')
.get(session.class_id, userId);
} else {
return !!db.prepare('SELECT 1 FROM classroom_invites WHERE session_id=? AND user_id=?')
.get(session.id, userId);
}
}
/* POST /api/classroom — teacher creates session */
function createSession(req, res) {
const { class_id, user_ids, title = '' } = req.body;
const teacher = req.user;
if (!class_id && (!user_ids || !user_ids.length)) {
return res.status(400).json({ error: 'Укажите class_id или user_ids' });
}
if (class_id) {
// verify teacher owns class
const cls = teacher.role === 'admin'
? db.prepare('SELECT id, name FROM classes WHERE id=?').get(class_id)
: db.prepare('SELECT id, name FROM classes WHERE id=? AND teacher_id=?').get(class_id, teacher.id);
if (!cls) return res.status(403).json({ error: 'Нет доступа к классу' });
// end any active session for this class
db.prepare(`UPDATE classroom_sessions SET status='ended', ended_at=datetime('now')
WHERE class_id=? AND status='active'`).run(class_id);
}
const { lastInsertRowid } = db.prepare(
`INSERT INTO classroom_sessions (class_id, teacher_id, title) VALUES (?,?,?)`
).run(class_id || null, teacher.id, title);
const sessionId = Number(lastInsertRowid);
// create first page
db.prepare('INSERT INTO classroom_pages (session_id, page_num) VALUES (?,1)').run(sessionId);
// for personal sessions — save invites
if (!class_id && user_ids) {
const ins = db.prepare('INSERT OR IGNORE INTO classroom_invites (session_id, user_id) VALUES (?,?)');
for (const uid of user_ids) ins.run(sessionId, uid);
}
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
emitToSession(sessionId, {
type: 'classroom_started',
sessionId,
title,
classId: class_id || null,
teacherName: teacher.name,
});
res.json(session);
}
/* GET /api/classroom/:id */
function getSession(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const pageCount = db.prepare('SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?')
.get(sessionId).c;
const attendance = db.prepare(`
SELECT a.user_id, u.name, a.joined_at, a.left_at
FROM classroom_attendance a
JOIN users u ON u.id = a.user_id
WHERE a.session_id=? ORDER BY a.joined_at
`).all(sessionId);
const drawAllowed = canDraw(sessionId, req.user.id, session);
res.json({ ...session, pageCount, attendance, canDraw: drawAllowed });
}
/* DELETE /api/classroom/:id */
function endSession(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare(`UPDATE classroom_sessions SET status='ended', ended_at=datetime('now') WHERE id=?`)
.run(sessionId);
_raisedHands.delete(sessionId);
db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId);
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
res.json({ ok: true });
}
/* GET /api/classroom/class/:classId/active */
function getActiveSession(req, res) {
const classId = Number(req.params.classId);
if (req.user.role !== 'teacher' && req.user.role !== 'admin') {
const isMember = db.prepare('SELECT 1 FROM class_members WHERE class_id=? AND user_id=?')
.get(classId, req.user.id);
if (!isMember) return res.status(403).json({ error: 'Нет доступа' });
}
const session = db.prepare(
`SELECT * FROM classroom_sessions WHERE class_id=? AND status='active' ORDER BY id DESC LIMIT 1`
).get(classId);
if (!session) return res.json({ active: false });
res.json({ active: true, session });
}
/* GET /api/classroom/my/active — personal sessions for current user */
function getMyActive(req, res) {
const userId = req.user.id;
const sessions = db.prepare(`
SELECT s.* FROM classroom_sessions s
JOIN classroom_invites i ON i.session_id = s.id
WHERE i.user_id=? AND s.status='active'
ORDER BY s.id DESC
`).all(userId);
res.json({ sessions });
}
/* POST /api/classroom/:id/join */
function joinSession(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
db.prepare(`
INSERT INTO classroom_attendance (session_id, user_id)
VALUES (?,?)
ON CONFLICT(session_id, user_id) DO UPDATE SET joined_at=datetime('now'), left_at=NULL
`).run(sessionId, req.user.id);
emitToSession(sessionId, {
type: 'classroom_user_joined',
sessionId,
userId: req.user.id,
userName: req.user.name,
});
// If this user already has draw permission (e.g. they rejoined after a page refresh), notify them
const drawAllowed = canDraw(sessionId, req.user.id, session) && session.teacher_id !== req.user.id;
if (drawAllowed) {
emit(req.user.id, { type: 'classroom_draw_permitted', sessionId });
}
res.json({ ok: true, canDraw: drawAllowed });
}
/* POST /api/classroom/:id/leave */
function leaveSession(req, res) {
const sessionId = Number(req.params.id);
db.prepare(`UPDATE classroom_attendance SET left_at=datetime('now')
WHERE session_id=? AND user_id=? AND left_at IS NULL`)
.run(sessionId, req.user.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (session) {
emitToSession(sessionId, {
type: 'classroom_user_left',
sessionId,
userId: req.user.id,
});
}
res.json({ ok: true });
}
/* POST /api/classroom/:id/chat */
function sendChat(req, res) {
const sessionId = Number(req.params.id);
const { message = '', attachment_url, attachment_type } = req.body;
const text = message.trim().slice(0, 2000);
if (!text && !attachment_url) return res.status(400).json({ error: 'Пустое сообщение' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const { lastInsertRowid } = db.prepare(
'INSERT INTO classroom_chat (session_id, user_id, message, attachment_url, attachment_type) VALUES (?,?,?,?,?)'
).run(sessionId, req.user.id, text, attachment_url || null, attachment_type || null);
const row = db.prepare('SELECT * FROM classroom_chat WHERE id=?').get(lastInsertRowid);
emitToSession(sessionId, {
type: 'classroom_chat',
sessionId,
id: row.id,
userId: req.user.id,
userName: req.user.name,
message: text,
createdAt: row.created_at,
attachmentUrl: row.attachment_url || null,
attachmentType: row.attachment_type || null,
});
res.json(row);
}
/* GET /api/classroom/:id/chat */
function getChat(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const sinceId = Number(req.query.since_id) || 0;
const messages = sinceId
? db.prepare(`
SELECT c.*, u.name AS user_name
FROM classroom_chat c
JOIN users u ON u.id = c.user_id
WHERE c.session_id=? AND c.id > ?
ORDER BY c.id ASC LIMIT 100
`).all(sessionId, sinceId)
: db.prepare(`
SELECT c.*, u.name AS user_name
FROM classroom_chat c
JOIN users u ON u.id = c.user_id
WHERE c.session_id=?
ORDER BY c.id DESC LIMIT 200
`).all(sessionId).reverse();
// Attach reactions to each message
if (messages.length > 0) {
const ids = messages.map(m => m.id);
const reactions = db.prepare(
`SELECT chat_id, reaction, COUNT(*) AS cnt,
GROUP_CONCAT(user_id) AS uids
FROM classroom_chat_reactions
WHERE chat_id IN (${ids.map(() => '?').join(',')})
GROUP BY chat_id, reaction`
).all(...ids);
const rmap = {};
reactions.forEach(r => {
if (!rmap[r.chat_id]) rmap[r.chat_id] = {};
rmap[r.chat_id][r.reaction] = {
count: r.cnt,
mine: (r.uids || '').split(',').includes(String(req.user.id)),
};
});
messages.forEach(m => {
m.reactions = rmap[m.id] || {};
});
}
res.json({ messages });
}
/* GET /api/classroom/:id/participants — active participants (for all session members) */
function getParticipants(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const participants = db.prepare(`
SELECT a.user_id, u.name, a.joined_at
FROM classroom_attendance a
JOIN users u ON u.id = a.user_id
WHERE a.session_id=? AND a.left_at IS NULL
ORDER BY a.joined_at
`).all(sessionId);
res.json({ participants });
}
/* GET /api/classroom/:id/attendance */
function getAttendance(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const attendance = db.prepare(`
SELECT a.*, u.name AS user_name
FROM classroom_attendance a
JOIN users u ON u.id = a.user_id
WHERE a.session_id=? ORDER BY a.joined_at
`).all(sessionId);
res.json({ attendance });
}
/* POST /api/classroom/:id/signal — WebRTC signaling relay */
function signal(req, res) {
const sessionId = Number(req.params.id);
const { target_user_id, payload } = req.body;
if (!target_user_id || !payload) return res.status(400).json({ error: 'target_user_id и payload обязательны' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
emit(target_user_id, {
type: 'classroom_signal',
sessionId,
from: req.user.id,
payload,
});
res.json({ ok: true });
}
/* GET /api/classroom/my/session — active session for current user (teacher or student) */
function getMySession(req, res) {
const userId = req.user.id;
const role = req.user.role;
let session = null;
if (role === 'teacher' || role === 'admin') {
session = db.prepare(
`SELECT * FROM classroom_sessions WHERE teacher_id=? AND status='active' ORDER BY id DESC LIMIT 1`
).get(userId);
} else {
// Class-based session: student is a member of a class that has an active session
session = db.prepare(`
SELECT cs.* FROM classroom_sessions cs
JOIN class_members cm ON cm.class_id = cs.class_id
WHERE cm.user_id=? AND cs.status='active'
ORDER BY cs.id DESC LIMIT 1
`).get(userId);
// Personal session: student was invited
if (!session) {
session = db.prepare(`
SELECT cs.* FROM classroom_sessions cs
JOIN classroom_invites ci ON ci.session_id = cs.id
WHERE ci.user_id=? AND cs.status='active'
ORDER BY cs.id DESC LIMIT 1
`).get(userId);
}
}
if (!session) return res.json({ session: null });
const pageCount = db.prepare('SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?')
.get(session.id).c;
const attendance = db.prepare(`
SELECT a.user_id, u.name, a.joined_at, a.left_at
FROM classroom_attendance a
JOIN users u ON u.id = a.user_id
WHERE a.session_id=? ORDER BY a.joined_at
`).all(session.id);
// Did this user join before (even if they later left)?
const wasJoined = attendance.some(a => a.user_id === userId);
res.json({ session: { ...session, pageCount, attendance }, wasJoined });
}
/* GET /api/classroom/online-students — list of students currently online (SSE connected) */
function getOnlineStudents(req, res) {
const onlineIds = getOnlineUserIds();
if (!onlineIds.length) return res.json({ students: [] });
const placeholders = onlineIds.map(() => '?').join(',');
const students = db.prepare(
`SELECT id, name, email FROM users
WHERE id IN (${placeholders}) AND role IN ('student','free_student')
ORDER BY name`
).all(...onlineIds);
res.json({ students });
}
/* ── In-memory raised hands: sessionId -> Set<userId> ─────────────────── */
const _raisedHands = new Map();
/* POST /api/classroom/:id/pages — add a page */
function addPage(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const maxFromStrokes = db.prepare(
'SELECT COALESCE(MAX(page_num), 1) AS m FROM classroom_strokes WHERE session_id=?'
).get(sessionId).m;
const maxFromPages = db.prepare(
'SELECT COALESCE(MAX(page_num), 1) AS m FROM classroom_pages WHERE session_id=?'
).get(sessionId).m;
const newPage = Math.max(session.current_page, maxFromStrokes, maxFromPages) + 1;
const template = req.body?.template || 'blank';
db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)').run(sessionId, newPage, template);
db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newPage, sessionId);
emitToSession(sessionId, { type: 'classroom_page_added', sessionId, pageNum: newPage, template });
res.json({ pageNum: newPage, template });
}
/* PUT /api/classroom/:id/page — change current page */
function changePage(req, res) {
const sessionId = Number(req.params.id);
const { page_num } = req.body;
if (!page_num) return res.status(400).json({ error: 'page_num required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(page_num, sessionId);
emitToSession(sessionId, { type: 'classroom_page_changed', sessionId, pageNum: Number(page_num) });
res.json({ pageNum: Number(page_num) });
}
/* PATCH /api/classroom/:id/page-template — update template for current page */
function updatePageTemplate(req, res) {
const sessionId = Number(req.params.id);
const { template } = req.body;
if (!template) return res.status(400).json({ error: 'template required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('UPDATE classroom_pages SET template=? WHERE session_id=? AND page_num=?')
.run(template, sessionId, session.current_page);
emitToSession(sessionId, { type: 'classroom_template_changed', sessionId, pageNum: session.current_page, template });
res.json({ ok: true, template });
}
/* POST /api/classroom/:id/hand — raise hand */
function raiseHand(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
if (!_raisedHands.has(sessionId)) _raisedHands.set(sessionId, new Map());
_raisedHands.get(sessionId).set(req.user.id, req.user.name);
emitToSession(sessionId, {
type: 'classroom_hand_raised',
sessionId,
userId: req.user.id,
userName: req.user.name,
});
res.json({ ok: true });
}
/* DELETE /api/classroom/:id/hand — lower hand */
function lowerHand(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=?`).get(sessionId);
const map = _raisedHands.get(sessionId);
if (map) map.delete(req.user.id);
if (session) {
emitToSession(sessionId, { type: 'classroom_hand_lowered', sessionId, userId: req.user.id });
}
res.json({ ok: true });
}
/* GET /api/classroom/:id/hands — get current raised hands */
function getHands(req, res) {
const sessionId = Number(req.params.id);
const map = _raisedHands.get(sessionId);
const hands = map ? [...map.entries()].map(([userId, userName]) => ({ userId, userName })) : [];
res.json({ hands });
}
/* POST /api/classroom/:id/strokes — teacher saves batch of strokes */
function postStrokes(req, res) {
const sessionId = Number(req.params.id);
const { strokes, page_num = 1 } = req.body;
if (!Array.isArray(strokes) || !strokes.length)
return res.status(400).json({ error: 'strokes array required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!canDraw(sessionId, req.user.id, session) && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
// Get current max seq for this session+page
const maxSeq = db.prepare(
'SELECT COALESCE(MAX(seq), 0) AS m FROM classroom_strokes WHERE session_id=? AND page_num=?'
).get(sessionId, page_num).m;
const insert = db.prepare(
'INSERT INTO classroom_strokes (session_id, page_num, user_id, tool, data, seq) VALUES (?,?,?,?,?,?)'
);
const saved = [];
let seq = maxSeq;
const insertMany = db.transaction(() => {
for (const s of strokes) {
seq++;
const { lastInsertRowid } = insert.run(sessionId, page_num, req.user.id, s.tool || 'pencil', JSON.stringify(s.data), seq);
saved.push({ id: Number(lastInsertRowid), tool: s.tool || 'pencil', data: s.data, seq });
}
});
insertMany();
emitToSession(sessionId, {
type: 'classroom_strokes',
sessionId,
pageNum: page_num,
strokes: saved,
userId: req.user.id,
});
res.json({ strokes: saved });
}
/* GET /api/classroom/:id/strokes?page_num=1&since_seq=N — load strokes for a page */
function getStrokes(req, res) {
const sessionId = Number(req.params.id);
const pageNum = Number(req.query.page_num) || 1;
const sinceSeq = req.query.since_seq !== undefined ? Number(req.query.since_seq) : -1;
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Не найдено' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
const rows = sinceSeq >= 0
? db.prepare('SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? AND seq > ? ORDER BY seq').all(sessionId, pageNum, sinceSeq)
: db.prepare('SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq').all(sessionId, pageNum);
const strokes = rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
const pageRow = db.prepare('SELECT template FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum);
const template = pageRow?.template || 'blank';
res.json({ strokes, template });
}
/* PATCH /api/classroom/:id/strokes/:strokeId — update image position/size */
function updateStroke(req, res) {
const sessionId = Number(req.params.id);
const strokeId = Number(req.params.strokeId);
const { data } = req.body;
if (!data) return res.status(400).json({ error: 'data required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!canDraw(sessionId, req.user.id, session) && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const existing = db.prepare('SELECT id, page_num FROM classroom_strokes WHERE id=? AND session_id=?').get(strokeId, sessionId);
if (!existing) return res.status(404).json({ error: 'Штрих не найден' });
db.prepare('UPDATE classroom_strokes SET data=? WHERE id=?').run(JSON.stringify(data), strokeId);
emitToSession(sessionId, {
type: 'classroom_stroke_updated',
sessionId,
strokeId,
pageNum: existing.page_num,
data,
});
res.json({ ok: true });
}
/* DELETE /api/classroom/:id/strokes/:strokeId — undo a stroke */
function deleteStroke(req, res) {
const sessionId = Number(req.params.id);
const strokeId = Number(req.params.strokeId);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const stroke = db.prepare('SELECT page_num FROM classroom_strokes WHERE id=? AND session_id=?').get(strokeId, sessionId);
if (!stroke) return res.status(404).json({ error: 'Штрих не найден' });
db.prepare('DELETE FROM classroom_strokes WHERE id=?').run(strokeId);
emitToSession(sessionId, {
type: 'classroom_stroke_deleted',
sessionId,
strokeId,
pageNum: stroke.page_num,
});
res.json({ ok: true });
}
/* POST /api/classroom/:id/clear-page — teacher clears all strokes on a page */
function clearPage(req, res) {
const sessionId = Number(req.params.id);
const { page_num = 1 } = req.body;
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, page_num);
emitToSession(sessionId, { type: 'classroom_page_cleared', sessionId, pageNum: Number(page_num) });
res.json({ ok: true });
}
/* POST /api/classroom/:id/mute — teacher mutes a student */
function mutePeer(req, res) {
const sessionId = Number(req.params.id);
const { user_id } = req.body;
if (!user_id) return res.status(400).json({ error: 'user_id required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
emit(user_id, { type: 'classroom_muted', sessionId, by: req.user.id });
res.json({ ok: true });
}
/* POST /api/classroom/:id/stroke-preview — broadcast live drawing state (not saved to DB) */
function previewStroke(req, res) {
const sessionId = Number(req.params.id);
const { live_id, tool, data, page_num = 1, cancel } = req.body;
if (!live_id) return res.status(400).json({ error: 'live_id required' });
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!canDraw(sessionId, req.user.id, session) && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const payload = {
type: 'classroom_stroke_preview',
sessionId,
pageNum: Number(page_num),
liveId: live_id,
tool,
data,
cancel: cancel || false,
userId: req.user.id,
userName: req.user.name || req.user.email,
};
emitToSession(sessionId, payload);
res.json({ ok: true });
}
/* POST /api/classroom/:id/chat/:msgId/pin — teacher pins a message */
function pinMessage(req, res) {
const sessionId = Number(req.params.id);
const msgId = Number(req.params.msgId);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const msg = db.prepare('SELECT * FROM classroom_chat WHERE id=? AND session_id=?').get(msgId, sessionId);
if (!msg) return res.status(404).json({ error: 'Сообщение не найдено' });
// Toggle pin
const newPinned = msg.pinned ? 0 : 1;
db.prepare('UPDATE classroom_chat SET pinned=? WHERE id=?').run(newPinned, msgId);
emitToSession(sessionId, {
type: 'classroom_message_pinned',
sessionId, msgId, pinned: !!newPinned,
message: msg.message,
});
res.json({ ok: true, pinned: !!newPinned });
}
/* POST /api/classroom/:id/allow-draw/:userId — teacher grants draw permission */
function allowDraw(req, res) {
const sessionId = Number(req.params.id);
const targetId = Number(req.params.userId);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare(
'INSERT OR IGNORE INTO classroom_draw_permissions (session_id, user_id) VALUES (?,?)'
).run(sessionId, targetId);
emit(targetId, { type: 'classroom_draw_permitted', sessionId });
res.json({ ok: true });
}
/* DELETE /api/classroom/:id/allow-draw/:userId — teacher revokes draw permission */
function revokeDraw(req, res) {
const sessionId = Number(req.params.id);
const targetId = Number(req.params.userId);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
db.prepare(
'DELETE FROM classroom_draw_permissions WHERE session_id=? AND user_id=?'
).run(sessionId, targetId);
emit(targetId, { type: 'classroom_draw_revoked', sessionId });
res.json({ ok: true });
}
/* POST /api/classroom/:id/cursor — teacher broadcasts cursor position */
function broadcastCursor(req, res) {
const sessionId = Number(req.params.id);
const { x, y, page_num = 1 } = req.body;
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (!hasAccess(session, req.user.id, req.user.role))
return res.status(403).json({ error: 'Нет доступа' });
emitToSession(sessionId, {
type: 'classroom_cursor', sessionId,
x, y, pageNum: Number(page_num),
userId: req.user.id,
userName: req.user.name || req.user.email,
});
res.json({ ok: true });
}
/* POST /api/classroom/:id/screen — teacher announces screen share start */
function screenStart(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
emitToSession(sessionId, { type: 'classroom_screen_started', sessionId });
res.json({ ok: true });
}
/* DELETE /api/classroom/:id/screen — teacher announces screen share stop */
function screenStop(req, res) {
const sessionId = Number(req.params.id);
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
emitToSession(sessionId, { type: 'classroom_screen_stopped', sessionId });
res.json({ ok: true });
}
/* ── Chat: upload image attachment ──────────────────────────────────────── */
function uploadChatAttachment(req, res) {
if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
const url = `/uploads/chat/${req.file.filename}`;
const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file';
res.json({ url, type, name: req.file.originalname });
}
/* ── Chat: toggle reaction ───────────────────────────────────────────────── */
const ALLOWED_REACTIONS = ['like', 'heart', 'question', 'idea', 'wow'];
function reactToMessage(req, res) {
const chatId = Number(req.params.msgId);
const { reaction } = req.body;
if (!ALLOWED_REACTIONS.includes(reaction))
return res.status(400).json({ error: 'Неизвестная реакция' });
const msg = db.prepare('SELECT * FROM classroom_chat WHERE id=?').get(chatId);
if (!msg) return res.status(404).json({ error: 'Сообщение не найдено' });
const existing = db.prepare(
'SELECT id FROM classroom_chat_reactions WHERE chat_id=? AND user_id=? AND reaction=?'
).get(chatId, req.user.id, reaction);
let added;
if (existing) {
db.prepare('DELETE FROM classroom_chat_reactions WHERE id=?').run(existing.id);
added = false;
} else {
db.prepare('INSERT INTO classroom_chat_reactions (chat_id, user_id, reaction) VALUES (?,?,?)')
.run(chatId, req.user.id, reaction);
added = true;
}
const counts = db.prepare(
`SELECT reaction, COUNT(*) AS cnt, GROUP_CONCAT(user_id) AS uids
FROM classroom_chat_reactions WHERE chat_id=? GROUP BY reaction`
).all(chatId);
const reactionsMap = {};
counts.forEach(r => {
reactionsMap[r.reaction] = { count: r.cnt, uids: r.uids };
});
emitToSession(msg.session_id, {
type: 'classroom_reaction',
sessionId: msg.session_id,
chatId, reaction, userId: req.user.id, added,
reactions: reactionsMap,
});
res.json({ ok: true, added, reactions: reactionsMap });
}
/* ── Session notes (per user) ───────────────────────────────────────────── */
function getNotes(req, res) {
const sessionId = Number(req.params.id);
const row = db.prepare(
'SELECT content FROM classroom_notes WHERE session_id=? AND user_id=?'
).get(sessionId, req.user.id);
res.json({ content: row?.content || '' });
}
function saveNotes(req, res) {
const sessionId = Number(req.params.id);
const { content = '' } = req.body;
db.prepare(`
INSERT INTO classroom_notes (session_id, user_id, content, updated_at)
VALUES (?,?,?,datetime('now'))
ON CONFLICT(session_id, user_id)
DO UPDATE SET content=excluded.content, updated_at=excluded.updated_at
`).run(sessionId, req.user.id, content.slice(0, 50000));
res.json({ ok: true });
}
/* ── Lesson templates ───────────────────────────────────────────────────── */
function getTemplates(req, res) {
const templates = db.prepare(
'SELECT id, title, description, created_at FROM classroom_templates WHERE teacher_id=? ORDER BY created_at DESC'
).all(req.user.id);
res.json({ templates });
}
function saveTemplate(req, res) {
const sessionId = Number(req.params.id);
const { title, description = '' } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Укажите название шаблона' });
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не найдена' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const pages = db.prepare(
'SELECT * FROM classroom_pages WHERE session_id=? ORDER BY page_num'
).all(sessionId);
const pagesData = pages.map(p => {
const strokes = db.prepare(
'SELECT tool, data FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq'
).all(sessionId, p.page_num);
return {
page_num: p.page_num,
template: p.template || 'blank',
strokes: strokes.map(s => ({ tool: s.tool, data: JSON.parse(s.data) })),
};
});
const { lastInsertRowid } = db.prepare(
'INSERT INTO classroom_templates (teacher_id, title, description, pages_data) VALUES (?,?,?,?)'
).run(req.user.id, title.trim(), description.slice(0, 500), JSON.stringify(pagesData));
res.json({ id: lastInsertRowid, ok: true });
}
function deleteTemplate(req, res) {
const id = Number(req.params.tid);
db.prepare('DELETE FROM classroom_templates WHERE id=? AND teacher_id=?').run(id, req.user.id);
res.json({ ok: true });
}
function loadTemplate(req, res) {
const sessionId = Number(req.params.id);
const { template_id } = req.body;
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Нет доступа' });
const tmpl = db.prepare('SELECT * FROM classroom_templates WHERE id=?').get(template_id);
if (!tmpl) return res.status(404).json({ error: 'Шаблон не найден' });
const pagesData = JSON.parse(tmpl.pages_data || '[]');
// Clear current session data
db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId);
db.prepare('DELETE FROM classroom_pages WHERE session_id=?').run(sessionId);
// Restore from template
pagesData.forEach(p => {
db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)')
.run(sessionId, p.page_num, p.template || 'blank');
(p.strokes || []).forEach(s => {
db.prepare('INSERT INTO classroom_strokes (session_id, page_num, tool, data) VALUES (?,?,?,?)')
.run(sessionId, p.page_num, s.tool, JSON.stringify(s.data));
});
});
// Broadcast: clients reload
emitToSession(sessionId, {
type: 'classroom_template_loaded',
sessionId,
pages: pagesData.length,
});
res.json({ ok: true, pages: pagesData.length });
}
module.exports = {
createSession,
getSession,
endSession,
getActiveSession,
getMyActive,
joinSession,
leaveSession,
sendChat,
getChat,
getAttendance,
getParticipants,
signal,
getOnlineStudents,
getMySession,
postStrokes,
getStrokes,
deleteStroke,
updateStroke,
addPage,
changePage,
updatePageTemplate,
raiseHand,
lowerHand,
getHands,
mutePeer,
screenStart,
screenStop,
clearPage,
previewStroke,
broadcastCursor,
pinMessage,
allowDraw,
revokeDraw,
uploadChatAttachment,
reactToMessage,
getNotes,
saveNotes,
getTemplates,
saveTemplate,
deleteTemplate,
loadTemplate,
};
@@ -0,0 +1,69 @@
'use strict';
const db = require('../db/db');
function _tier(total, correct) {
if (total === 0) return 'locked';
const pct = correct / total * 100;
if (total >= 10 && pct >= 90) return 'platinum';
if (total >= 5 && pct >= 80) return 'gold';
if (total >= 3 && pct >= 50) return 'silver';
if (correct >= 1) return 'bronze';
return 'locked';
}
/* ── GET /api/collection ──────────────────────────────────────────────── */
function getCollection(req, res) {
const rows = db.prepare(`
SELECT
t.id AS topic_id,
t.name AS topic_name,
s.name AS subject_name,
s.slug AS subject_slug,
s.icon AS subject_icon,
COALESCE(agg.total_attempts, 0) AS total_attempts,
COALESCE(agg.correct_count, 0) AS correct_count,
agg.first_seen_at
FROM topics t
JOIN subjects s ON s.id = t.subject_id
LEFT JOIN (
SELECT q.topic_id,
COUNT(ua.id) AS total_attempts,
SUM(CASE WHEN ua.is_correct=1 THEN 1 ELSE 0 END) AS correct_count,
MIN(ua.answered_at) AS first_seen_at
FROM user_answers ua
JOIN questions q ON q.id = ua.question_id
WHERE ua.session_id IN (
SELECT id FROM test_sessions WHERE user_id = ? AND status = 'completed'
)
GROUP BY q.topic_id
) agg ON agg.topic_id = t.id
ORDER BY s.slug, t.order_index, t.name
`).all(req.user.id);
const cards = rows.map(r => ({
topicId: r.topic_id,
topicName: r.topic_name,
subjectName: r.subject_name,
subjectSlug: r.subject_slug,
subjectIcon: r.subject_icon,
tier: _tier(r.total_attempts, r.correct_count),
totalAttempts: r.total_attempts,
correctCount: r.correct_count,
masteryPct: r.total_attempts > 0 ? Math.round(r.correct_count / r.total_attempts * 100) : 0,
firstSeenAt: r.first_seen_at || null,
}));
const unlocked = cards.filter(c => c.tier !== 'locked').length;
res.json({
totalTopics: cards.length,
unlockedTopics: unlocked,
platinumCount: cards.filter(c => c.tier === 'platinum').length,
goldCount: cards.filter(c => c.tier === 'gold').length,
silverCount: cards.filter(c => c.tier === 'silver').length,
bronzeCount: cards.filter(c => c.tier === 'bronze').length,
cards,
});
}
module.exports = { getCollection };
+508
View File
@@ -0,0 +1,508 @@
const db = require('../db/db');
/* ── helpers ──────────────────────────────────────────────────────────── */
// Reused SQL fragment: user's completed-lesson count for a course (param: user_id)
const DONE_COUNT_SUBQ = `(SELECT COUNT(*) FROM lesson_progress lp
JOIN lessons l2 ON lp.lesson_id = l2.id
WHERE l2.course_id = c.id AND lp.user_id = ? AND lp.completed = 1) AS done_count`;
function courseRow(row) {
return {
id: row.id,
subjectSlug: row.subject_slug,
title: row.title,
description: row.description || '',
coverEmoji: row.cover_emoji,
orderIndex: row.order_index,
isPublished: row.is_published === 1,
createdBy: row.created_by,
createdAt: row.created_at,
lessonCount: row.lesson_count ?? 0,
doneCount: row.done_count ?? 0,
};
}
function progressSubquery(role) {
return role === 'student' ? 'AND l.is_published = 1' : '';
}
/* ── GET /api/courses ─────────────────────────────────────────────────── */
function list(req, res) {
const { subject } = req.query;
const role = req.user.role;
const uid = req.user.id;
let where = role === 'student' ? 'WHERE c.is_published = 1' : 'WHERE 1=1';
const args = [];
if (subject) { where += ' AND c.subject_slug = ?'; args.push(subject); }
const rows = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ}
FROM courses c ${where}
ORDER BY c.subject_slug, c.order_index, c.id
`).all(uid, ...args);
res.json(rows.map(courseRow));
}
/* ── GET /api/courses/search?q=… ─────────────────────────────────────── */
function search(req, res) {
const q = (req.query.q || '').trim();
const role = req.user.role;
const uid = req.user.id;
if (!q) return res.json({ courses: [], lessons: [] });
const like = `%${q}%`;
const pubC = role === 'student' ? 'AND c.is_published = 1' : '';
const pubL = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : '';
const courses = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ}
FROM courses c WHERE (c.title LIKE ? OR c.description LIKE ?) ${pubC}
ORDER BY c.subject_slug, c.order_index LIMIT 20
`).all(uid, like, like).map(courseRow);
const lessons = db.prepare(`
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug,
lp.completed
FROM lessons l
JOIN courses c ON l.course_id = c.id
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = ?
WHERE l.title LIKE ? ${pubL}
ORDER BY c.subject_slug, l.order_index LIMIT 30
`).all(uid, like);
res.json({ courses, lessons });
}
/* ── GET /api/courses/continue ───────────────────────────────────────── */
// Returns the last-in-progress lesson across all courses
function continueLesson(req, res) {
const uid = req.user.id;
const role = req.user.role;
const pub = role === 'student' ? 'AND l.is_published = 1 AND c.is_published = 1' : '';
// 1. last started but not finished lesson (progress row exists, completed=0)
let row = db.prepare(`
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug, c.cover_emoji
FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
JOIN courses c ON l.course_id = c.id
WHERE lp.user_id = ? AND lp.completed = 0 ${pub}
ORDER BY lp.updated_at DESC LIMIT 1
`).get(uid);
// 2. fallback: first unprogressed lesson in courses that have any progress
if (!row) {
row = db.prepare(`
SELECT l.id, l.title, l.course_id, c.title AS course_title, c.subject_slug, c.cover_emoji
FROM lessons l
JOIN courses c ON l.course_id = c.id
WHERE c.id IN (
SELECT DISTINCT l2.course_id FROM lesson_progress lp2
JOIN lessons l2 ON lp2.lesson_id = l2.id WHERE lp2.user_id = ?
)
AND l.id NOT IN (SELECT lesson_id FROM lesson_progress WHERE user_id = ?)
${pub}
ORDER BY l.course_id, l.order_index LIMIT 1
`).get(uid, uid);
}
res.json(row ? {
lessonId: row.id,
lessonTitle: row.title,
courseId: row.course_id,
courseTitle: row.course_title,
subjectSlug: row.subject_slug,
coverEmoji: row.cover_emoji,
} : null);
}
/* ── GET /api/courses/:id ─────────────────────────────────────────────── */
function get(req, res) {
const role = req.user.role;
const uid = req.user.id;
const row = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ}
FROM courses c WHERE c.id = ?
`).get(uid, req.params.id);
if (!row) return res.status(404).json({ error: 'Course not found' });
if (role === 'student' && !row.is_published)
return res.status(403).json({ error: 'Course not published' });
// sections
const sections = db.prepare(
'SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index, id'
).all(row.id);
// lessons grouped
const pubWhere = role === 'student' ? 'AND l.is_published = 1' : '';
const lessons = db.prepare(`
SELECT l.id, l.title, l.order_index, l.is_published, l.section_id, l.read_time,
lp.completed
FROM lessons l
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = ?
WHERE l.course_id = ? ${pubWhere}
ORDER BY l.order_index, l.id
`).all(uid, row.id);
res.json({
...courseRow(row),
sections: sections.map(s => ({ id: s.id, title: s.title, orderIndex: s.order_index })),
lessons: lessons.map(l => ({
id: l.id,
title: l.title,
orderIndex: l.order_index,
isPublished: l.is_published === 1,
sectionId: l.section_id,
readTime: l.read_time || 0,
completed: l.completed === 1,
})),
});
}
/* ── GET /api/courses/:id/stats?classId=X ────────────────────────────── */
function stats(req, res) {
const { classId } = req.query;
const course = db.prepare('SELECT id FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
// students in class (or all students who have progress)
let members;
if (classId) {
members = db.prepare(`
SELECT u.id FROM class_members cm JOIN users u ON cm.user_id = u.id
WHERE cm.class_id = ? AND u.role = 'student'
`).all(classId).map(r => r.id);
} else {
members = db.prepare(`
SELECT DISTINCT lp.user_id AS id FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id WHERE l.course_id = ?
`).all(course.id).map(r => r.id);
}
const total = members.length || 1;
const lessons = db.prepare(`
SELECT l.id, l.title, l.order_index,
(SELECT COUNT(*) FROM lesson_progress lp
WHERE lp.lesson_id = l.id AND lp.completed = 1
AND lp.user_id IN (${members.map(() => '?').join(',') || 'NULL'})) AS done_count
FROM lessons l WHERE l.course_id = ? ORDER BY l.order_index, l.id
`).all(...members, course.id);
res.json({ total, lessons: lessons.map(l => ({
id: l.id, title: l.title,
doneCount: l.done_count,
pct: Math.round(l.done_count / total * 100),
}))});
}
/* ── GET /api/courses/:id/analytics?classId=X ───────────────────────── */
function analytics(req, res) {
const { classId } = req.query;
const course = db.prepare('SELECT id, title FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
// all lessons in course
const lessons = db.prepare(
'SELECT id, title, order_index FROM lessons WHERE course_id = ? ORDER BY order_index, id'
).all(course.id);
const lessonIds = lessons.map(l => l.id);
const totalLessons = lessonIds.length;
// students
let students;
if (classId) {
students = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON cm.user_id = u.id
WHERE cm.class_id = ? AND u.role = 'student'
ORDER BY u.name
`).all(classId);
} else {
students = db.prepare(`
SELECT DISTINCT u.id, u.name, u.email FROM lesson_progress lp
JOIN lessons l ON lp.lesson_id = l.id
JOIN users u ON lp.user_id = u.id
WHERE l.course_id = ?
ORDER BY u.name
`).all(course.id);
}
if (!students.length) {
return res.json({
totalStudents: 0, totalLessons, avgPct: 0,
lessons: lessons.map(l => ({ id: l.id, title: l.title, doneCount: 0, pct: 0 })),
students: [], stuckStudents: [],
});
}
// Batch-fetch ALL progress for ALL students in ONE query (eliminates N+1)
const studentIds = students.map(s => s.id);
const lpRows = lessonIds.length && studentIds.length
? db.prepare(`
SELECT user_id, lesson_id, completed, updated_at FROM lesson_progress
WHERE user_id IN (${studentIds.map(() => '?').join(',')})
AND lesson_id IN (${lessonIds.map(() => '?').join(',')})
`).all(...studentIds, ...lessonIds)
: [];
// Index progress by [user_id][lesson_id]
const progressByUser = {};
for (const r of lpRows) {
if (!progressByUser[r.user_id]) progressByUser[r.user_id] = {};
progressByUser[r.user_id][r.lesson_id] = r;
}
// Per-lesson done count from the same data
const lessonDoneCount = {};
for (const lid of lessonIds) lessonDoneCount[lid] = 0;
for (const r of lpRows) {
if (r.completed === 1) lessonDoneCount[r.lesson_id] = (lessonDoneCount[r.lesson_id] || 0) + 1;
}
const studentData = students.map(s => {
const progressMap = progressByUser[s.id] || {};
const progress = Object.values(progressMap);
const doneCnt = progress.filter(p => p.completed === 1).length;
const pct = totalLessons > 0 ? Math.round(doneCnt / totalLessons * 100) : 0;
// find first incomplete lesson
let firstIncompleteIdx = -1;
for (let i = 0; i < lessons.length; i++) {
const p = progressMap[lessons[i].id];
if (!p || p.completed !== 1) { firstIncompleteIdx = i; break; }
}
// "stuck" = started course (done > 0), not finished, and last activity > 3 days ago
let stuck = false;
let stuckLesson = null;
let lastActivity = null;
if (doneCnt > 0 && doneCnt < totalLessons) {
const dates = progress.map(p => p.updated_at).filter(Boolean);
if (dates.length) {
lastActivity = dates.sort().pop();
const daysSince = (Date.now() - new Date(lastActivity.replace(' ', 'T') + 'Z').getTime()) / 86400000;
if (daysSince > 3) {
stuck = true;
stuckLesson = firstIncompleteIdx >= 0 ? lessons[firstIncompleteIdx] : null;
}
}
}
return {
id: s.id, name: s.name, email: s.email,
doneCount: doneCnt, pct, stuck,
stuckLessonId: stuckLesson?.id || null,
stuckLessonTitle: stuckLesson?.title || null,
lastActivity,
};
});
// Per-lesson stats from pre-computed counts (no extra queries)
const lessonStats = lessons.map(l => {
const doneCount = lessonDoneCount[l.id] || 0;
return {
id: l.id, title: l.title,
doneCount,
pct: students.length > 0 ? Math.round(doneCount / students.length * 100) : 0,
};
});
const avgPct = studentData.length > 0
? Math.round(studentData.reduce((s, d) => s + d.pct, 0) / studentData.length)
: 0;
res.json({
totalStudents: students.length,
totalLessons,
avgPct,
lessons: lessonStats,
students: studentData,
stuckStudents: studentData.filter(s => s.stuck),
});
}
/* ── POST /api/courses ────────────────────────────────────────────────── */
function create(req, res) {
const { subjectSlug, title, description, coverEmoji, orderIndex } = req.body;
if (!subjectSlug || !title)
return res.status(400).json({ error: 'subjectSlug and title required' });
const result = db.prepare(`
INSERT INTO courses (subject_slug, title, description, cover_emoji, order_index, created_by)
VALUES (?, ?, ?, ?, ?, ?)
`).run(subjectSlug, title.trim(), description || null, coverEmoji || '', orderIndex ?? 0, req.user.id);
res.status(201).json({ id: result.lastInsertRowid });
}
/* ── POST /api/courses/:id/duplicate ─────────────────────────────────── */
function duplicate(req, res) {
const src = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id);
if (!src) return res.status(404).json({ error: 'Course not found' });
let newCourseId;
db.transaction(() => {
const cr = db.prepare(`
INSERT INTO courses (subject_slug, title, description, cover_emoji, order_index, is_published, created_by)
VALUES (?, ?, ?, ?, ?, 0, ?)
`).run(src.subject_slug, src.title + ' (копия)', src.description, src.cover_emoji, src.order_index, req.user.id);
newCourseId = cr.lastInsertRowid;
// duplicate sections
const secMap = {};
const sections = db.prepare('SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index').all(src.id);
for (const s of sections) {
const sr = db.prepare('INSERT INTO course_sections (course_id, title, order_index) VALUES (?, ?, ?)').run(newCourseId, s.title, s.order_index);
secMap[s.id] = sr.lastInsertRowid;
}
// duplicate lessons + blocks
const lessons = db.prepare('SELECT * FROM lessons WHERE course_id = ? ORDER BY order_index').all(src.id);
for (const l of lessons) {
const lr = db.prepare(`
INSERT INTO lessons (course_id, title, order_index, section_id, read_time)
VALUES (?, ?, ?, ?, ?)
`).run(newCourseId, l.title, l.order_index, l.section_id ? (secMap[l.section_id] || null) : null, l.read_time || 0);
const newLid = lr.lastInsertRowid;
const blocks = db.prepare('SELECT * FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index').all(l.id);
for (const b of blocks) {
db.prepare('INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)').run(newLid, b.type, b.order_index, b.data);
}
}
})();
res.status(201).json({ id: newCourseId });
}
/* ── PUT /api/courses/:id ─────────────────────────────────────────────── */
function update(req, res) {
const row = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Course not found' });
const { title, description, coverEmoji, orderIndex, isPublished, subjectSlug } = req.body;
db.prepare(`
UPDATE courses SET title=?,description=?,cover_emoji=?,order_index=?,is_published=?,subject_slug=? WHERE id=?
`).run(
title ?? row.title,
description !== undefined ? description : row.description,
coverEmoji ?? row.cover_emoji,
orderIndex ?? row.order_index,
isPublished !== undefined ? (isPublished ? 1 : 0) : row.is_published,
subjectSlug ?? row.subject_slug,
row.id
);
res.json({ ok: true });
}
/* ── DELETE /api/courses/:id ──────────────────────────────────────────── */
function remove(req, res) {
const row = db.prepare('SELECT id FROM courses WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Course not found' });
db.prepare('DELETE FROM courses WHERE id = ?').run(row.id);
res.json({ ok: true });
}
/* ── SECTIONS ─────────────────────────────────────────────────────────── */
function listSections(req, res) {
const rows = db.prepare('SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index, id').all(req.params.id);
res.json(rows.map(s => ({ id: s.id, title: s.title, orderIndex: s.order_index })));
}
function createSection(req, res) {
const course = db.prepare('SELECT id FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
const { title, orderIndex } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
const r = db.prepare('INSERT INTO course_sections (course_id, title, order_index) VALUES (?, ?, ?)').run(course.id, title.trim(), orderIndex ?? 0);
res.status(201).json({ id: r.lastInsertRowid });
}
function updateSection(req, res) {
const s = db.prepare('SELECT * FROM course_sections WHERE id = ? AND course_id = ?').get(req.params.sid, req.params.id);
if (!s) return res.status(404).json({ error: 'Section not found' });
const { title, orderIndex } = req.body;
db.prepare('UPDATE course_sections SET title=?, order_index=? WHERE id=?').run(title ?? s.title, orderIndex ?? s.order_index, s.id);
res.json({ ok: true });
}
function deleteSection(req, res) {
const s = db.prepare('SELECT id FROM course_sections WHERE id = ? AND course_id = ?').get(req.params.sid, req.params.id);
if (!s) return res.status(404).json({ error: 'Section not found' });
// unlink lessons
db.prepare('UPDATE lessons SET section_id = NULL WHERE section_id = ?').run(s.id);
db.prepare('DELETE FROM course_sections WHERE id = ?').run(s.id);
res.json({ ok: true });
}
/* ── CLASS COURSES ────────────────────────────────────────────────────── */
function listClassCourses(req, res) {
const uid = req.user.id;
const role = req.user.role;
const pub = role === 'student' ? 'AND c.is_published = 1' : '';
const rows = db.prepare(`
SELECT c.*,
(SELECT COUNT(*) FROM lessons l WHERE l.course_id = c.id ${progressSubquery(role)}) AS lesson_count,
${DONE_COUNT_SUBQ},
cc.deadline
FROM class_courses cc
JOIN courses c ON cc.course_id = c.id
WHERE cc.class_id = ? ${pub}
ORDER BY cc.assigned_at
`).all(uid, req.params.classId);
res.json(rows.map(r => ({ ...courseRow(r), deadline: r.deadline })));
}
function assignCourseToClass(req, res) {
const { classId } = req.params;
const { courseId, deadline } = req.body;
if (!courseId) return res.status(400).json({ error: 'courseId required' });
try {
db.prepare(`
INSERT INTO class_courses (class_id, course_id, deadline, assigned_by)
VALUES (?, ?, ?, ?)
ON CONFLICT (class_id, course_id) DO UPDATE SET deadline=excluded.deadline
`).run(classId, courseId, deadline || null, req.user.id);
res.json({ ok: true });
} catch (e) { res.status(400).json({ error: e.message }); }
}
function unassignCourseFromClass(req, res) {
db.prepare('DELETE FROM class_courses WHERE class_id = ? AND course_id = ?').run(req.params.classId, req.params.courseId);
res.json({ ok: true });
}
/* ── PATCH /api/courses/:id/publish-all ──────────────────────────────── */
function publishAll(req, res) {
const course = db.prepare('SELECT id, created_by FROM courses WHERE id = ?').get(req.params.id);
if (!course) return res.status(404).json({ error: 'Course not found' });
if (req.user.role !== 'admin' && course.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const publish = req.body.publish !== false; // default: publish=true
const { changes } = db.prepare(
'UPDATE lessons SET is_published = ? WHERE course_id = ?'
).run(publish ? 1 : 0, course.id);
// Also publish/unpublish the course itself
db.prepare('UPDATE courses SET is_published = ? WHERE id = ?').run(publish ? 1 : 0, course.id);
res.json({ ok: true, lessonsUpdated: changes });
}
module.exports = {
list, search, continueLesson, get, stats, analytics, create, duplicate, update, remove,
listSections, createSection, updateSection, deleteSection,
listClassCourses, assignCourseToClass, unassignCourseFromClass,
publishAll,
};
+346
View File
@@ -0,0 +1,346 @@
const db = require('../db/db');
const path = require('path');
const fs = require('fs');
const { UPLOADS_DIR } = require('../config');
const { checkMagicBytes } = require('../utils/magic');
/* ── GET /api/files?subject=bio&my=1 ─────────────────────────────────── */
function listFiles(req, res) {
const { subject, my } = req.query;
const uid = req.user.id;
const isTeacher = ['teacher', 'admin'].includes(req.user.role);
let sql, args;
const cols = `f.id, f.title, f.description, f.original_name, f.mimetype, f.size,
f.subject_slug, f.is_public, f.folder_id, f.created_at,
u.name AS uploader_name`;
if (isTeacher && my === '1') {
sql = `SELECT ${cols} FROM files f JOIN users u ON u.id = f.uploaded_by WHERE f.uploaded_by = ?`;
args = [uid];
} else if (isTeacher) {
sql = `SELECT ${cols} FROM files f JOIN users u ON u.id = f.uploaded_by WHERE (f.uploaded_by = ? OR f.is_public = 1)`;
args = [uid];
} else {
sql = `
SELECT DISTINCT ${cols}
FROM files f JOIN users u ON u.id = f.uploaded_by
WHERE (
f.is_public = 1
OR EXISTS (SELECT 1 FROM file_access fa WHERE fa.file_id = f.id AND fa.type = 'user' AND fa.target_id = ?)
OR EXISTS (SELECT 1 FROM file_access fa JOIN class_members cm ON cm.class_id = fa.target_id AND cm.user_id = ? WHERE fa.file_id = f.id AND fa.type = 'class')
)
`;
args = [uid, uid];
}
if (subject) { sql += ' AND f.subject_slug = ?'; args.push(subject); }
sql += ' ORDER BY f.created_at DESC LIMIT 200';
res.json(db.prepare(sql).all(...args));
}
/* ── GET /api/files/folders ──────────────────────────────────────────── */
function listFolders(req, res) {
const uid = req.user.id;
const isTeacher = ['teacher', 'admin'].includes(req.user.role);
if (isTeacher) {
const rows = db.prepare(`
SELECT fo.id, fo.name, fo.created_by, fo.created_at,
(SELECT COUNT(*) FROM files f WHERE f.folder_id = fo.id) AS file_count,
(SELECT COUNT(*) FROM folder_access fa WHERE fa.folder_id = fo.id) AS access_count,
u.name AS creator_name
FROM folders fo JOIN users u ON u.id = fo.created_by
ORDER BY fo.name ASC
`).all();
return res.json(rows);
}
// Students: only folders with no restrictions, or where they have explicit access
const rows = db.prepare(`
SELECT fo.id, fo.name, fo.created_by, fo.created_at,
(SELECT COUNT(*) FROM files f WHERE f.folder_id = fo.id) AS file_count,
0 AS access_count,
u.name AS creator_name
FROM folders fo JOIN users u ON u.id = fo.created_by
WHERE (
NOT EXISTS (SELECT 1 FROM folder_access fa WHERE fa.folder_id = fo.id)
OR EXISTS (SELECT 1 FROM folder_access fa WHERE fa.folder_id = fo.id AND fa.type = 'user' AND fa.target_id = ?)
OR EXISTS (
SELECT 1 FROM folder_access fa
JOIN class_members cm ON cm.class_id = fa.target_id AND cm.user_id = ?
WHERE fa.folder_id = fo.id AND fa.type = 'class'
)
)
ORDER BY fo.name ASC
`).all(uid, uid);
res.json(rows);
}
/* ── POST /api/files/folders ─────────────────────────────────────────── */
function createFolder(req, res) {
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name required' });
const r = db.prepare('INSERT INTO folders (name, created_by) VALUES (?, ?)').run(name.trim(), req.user.id);
res.status(201).json({ id: r.lastInsertRowid, name: name.trim() });
}
/* ── PUT /api/files/folders/:id ──────────────────────────────────────── */
function renameFolder(req, res) {
const fo = db.prepare('SELECT * FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { name } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'name required' });
db.prepare('UPDATE folders SET name = ? WHERE id = ?').run(name.trim(), fo.id);
res.json({ ok: true });
}
/* ── DELETE /api/files/folders/:id ──────────────────────────────────── */
function deleteFolder(req, res) {
const fo = db.prepare('SELECT * FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
// Move files back to root before deleting
db.prepare('UPDATE files SET folder_id = NULL WHERE folder_id = ?').run(fo.id);
db.prepare('DELETE FROM folders WHERE id = ?').run(fo.id);
res.json({ ok: true });
}
/* ── PATCH /api/files/:id/move ───────────────────────────────────────── */
function moveFile(req, res) {
const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
if (req.user.role !== 'admin' && f.uploaded_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const folderId = req.body.folder_id ? Number(req.body.folder_id) : null;
if (folderId !== null) {
const fo = db.prepare('SELECT id FROM folders WHERE id = ?').get(folderId);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
}
db.prepare('UPDATE files SET folder_id = ? WHERE id = ?').run(folderId, f.id);
res.json({ ok: true });
}
/* ── POST /api/files ─────────────────────────────────────────────────── */
function uploadFile(req, res) {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const { title, description, subject_slug, is_public, folder_id } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
// Magic bytes verification — reject files whose content doesn't match declared MIME
const filePath = path.resolve(UPLOADS_DIR, req.file.filename);
if (!checkMagicBytes(filePath, req.file.mimetype)) {
try { fs.unlinkSync(filePath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
}
let r;
try {
r = db.prepare(`
INSERT INTO files (title, description, original_name, stored_name, mimetype, size, subject_slug, is_public, folder_id, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
title.trim(),
description?.trim() || null,
req.file.originalname,
req.file.filename,
req.file.mimetype,
req.file.size,
subject_slug || null,
is_public === '0' ? 0 : 1,
folder_id ? Number(folder_id) : null,
req.user.id
);
} catch (err) {
// Clean up orphan file if DB insert failed
const fp = path.resolve(UPLOADS_DIR, req.file.filename);
if (fp.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch {} }
throw err;
}
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/files/:id/download ─────────────────────────────────────── */
function downloadFile(req, res) {
const f = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
const uid = req.user.id;
const isTeacher = ['teacher', 'admin'].includes(req.user.role);
if (!f.is_public && !isTeacher && f.uploaded_by !== uid) {
const hasAccess = db.prepare(`
SELECT 1 FROM file_access fa
WHERE fa.file_id = ? AND (
(fa.type = 'user' AND fa.target_id = ?)
OR (fa.type = 'class' AND EXISTS (
SELECT 1 FROM class_members cm WHERE cm.class_id = fa.target_id AND cm.user_id = ?
))
) LIMIT 1
`).get(f.id, uid, uid);
if (!hasAccess) return res.status(403).json({ error: 'Access denied' });
}
const filePath = path.resolve(UPLOADS_DIR, f.stored_name);
if (!filePath.startsWith(UPLOADS_DIR + path.sep) && filePath !== UPLOADS_DIR)
return res.status(400).json({ error: 'Invalid file path' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File missing from storage' });
const inlineSafe = /^(image\/(png|jpeg|gif|webp)|application\/pdf)$/.test(f.mimetype);
const disposition = inlineSafe ? 'inline' : 'attachment';
const encoded = encodeURIComponent(f.original_name);
res.setHeader('Content-Disposition', `${disposition}; filename="${encoded}"; filename*=UTF-8''${encoded}`);
res.setHeader('Content-Type', f.mimetype || 'application/octet-stream');
res.sendFile(filePath);
}
/* ── DELETE /api/files/:id ───────────────────────────────────────────── */
function deleteFile(req, res) {
const f = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
const isAdmin = req.user.role === 'admin';
if (!isAdmin && f.uploaded_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const filePath = path.resolve(UPLOADS_DIR, f.stored_name);
if (filePath.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(filePath); } catch {} }
db.prepare('DELETE FROM files WHERE id = ?').run(req.params.id);
res.json({ ok: true });
}
/* ── GET /api/files/:id/access ───────────────────────────────────────── */
function getFileAccess(req, res) {
const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
const isAdmin = req.user.role === 'admin';
if (!isAdmin && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
const rows = db.prepare(`
SELECT fa.id, fa.type, fa.target_id,
CASE fa.type
WHEN 'class' THEN (SELECT name FROM classes WHERE id = fa.target_id)
WHEN 'user' THEN (SELECT name || ' (' || email || ')' FROM users WHERE id = fa.target_id)
END AS target_name
FROM file_access fa WHERE fa.file_id = ?
ORDER BY fa.type, fa.id
`).all(req.params.id);
res.json(rows);
}
/* ── POST /api/files/:id/assign ──────────────────────────────────────── */
function assignFile(req, res) {
const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
const isAdmin = req.user.role === 'admin';
if (!isAdmin && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
const { type, target_id, email } = req.body;
if (!['class', 'user'].includes(type)) return res.status(400).json({ error: 'type must be class or user' });
let tid = Number(target_id);
if (type === 'user' && email) {
const u = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim().toLowerCase());
if (!u) return res.status(404).json({ error: 'Пользователь не найден' });
tid = u.id;
}
if (!tid) return res.status(400).json({ error: 'target_id required' });
try {
db.prepare('INSERT INTO file_access (file_id, type, target_id) VALUES (?, ?, ?)').run(f.id, type, tid);
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Уже назначен' });
throw e;
}
res.json({ ok: true });
}
/* ── DELETE /api/files/:id/assign/:type/:targetId ────────────────────── */
function unassignFile(req, res) {
const f = db.prepare('SELECT id, uploaded_by FROM files WHERE id = ?').get(req.params.id);
if (!f) return res.status(404).json({ error: 'File not found' });
const isAdmin = req.user.role === 'admin';
if (!isAdmin && f.uploaded_by !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM file_access WHERE file_id = ? AND type = ? AND target_id = ?')
.run(f.id, req.params.type, Number(req.params.targetId));
res.json({ ok: true });
}
/* ── GET /api/files/folders/:id/access ───────────────────────────────── */
function getFolderAccess(req, res) {
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
const rows = db.prepare(`
SELECT fa.id, fa.type, fa.target_id,
CASE fa.type
WHEN 'class' THEN (SELECT name FROM classes WHERE id = fa.target_id)
WHEN 'user' THEN (SELECT name || ' (' || email || ')' FROM users WHERE id = fa.target_id)
END AS target_name
FROM folder_access fa WHERE fa.folder_id = ?
ORDER BY fa.type, fa.id
`).all(fo.id);
res.json(rows);
}
/* ── POST /api/files/folders/:id/assign ──────────────────────────────── */
function assignFolder(req, res) {
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { type, target_id, email } = req.body;
if (!['class', 'user'].includes(type)) return res.status(400).json({ error: 'type must be class or user' });
let tid = Number(target_id);
if (type === 'user' && email) {
const u = db.prepare('SELECT id FROM users WHERE email = ?').get(email.trim().toLowerCase());
if (!u) return res.status(404).json({ error: 'Пользователь не найден' });
tid = u.id;
}
if (!tid) return res.status(400).json({ error: 'target_id required' });
try {
db.prepare('INSERT INTO folder_access (folder_id, type, target_id) VALUES (?, ?, ?)').run(fo.id, type, tid);
} catch (e) {
if (e.message.includes('UNIQUE')) return res.status(409).json({ error: 'Уже назначен' });
throw e;
}
res.json({ ok: true });
}
/* ── DELETE /api/files/folders/:id/assign/:type/:targetId ────────────── */
function unassignFolder(req, res) {
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM folder_access WHERE folder_id = ? AND type = ? AND target_id = ?')
.run(fo.id, req.params.type, Number(req.params.targetId));
res.json({ ok: true });
}
/* ── DELETE /api/files/folders/:id/access (clear all) ───────────────── */
function clearFolderAccess(req, res) {
const fo = db.prepare('SELECT id, created_by FROM folders WHERE id = ?').get(req.params.id);
if (!fo) return res.status(404).json({ error: 'Folder not found' });
if (req.user.role !== 'admin' && fo.created_by !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM folder_access WHERE folder_id = ?').run(fo.id);
res.json({ ok: true });
}
module.exports = { listFiles, uploadFile, downloadFile, deleteFile, getFileAccess, assignFile, unassignFile, listFolders, createFolder, renameFolder, deleteFolder, moveFile, getFolderAccess, assignFolder, unassignFolder, clearFolderAccess };
@@ -0,0 +1,246 @@
const db = require('../db/db');
/* ── SM-2 algorithm ───────────────────────────────────────────────────────
quality: 0 = blackout, 1 = wrong, 2 = wrong but familiar,
3 = correct with difficulty, 4 = correct, 5 = perfect
─────────────────────────────────────────────────────────────────────── */
function sm2(easeFactor, intervalDays, repetitions, quality) {
let ef = easeFactor;
let n = repetitions;
let iv = intervalDays;
if (quality < 3) {
n = 0;
iv = 1;
} else {
if (n === 0) iv = 1;
else if (n === 1) iv = 6;
else iv = Math.round(iv * ef);
n++;
}
ef = Math.max(1.3, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
const due = new Date(Date.now() + iv * 86400000).toISOString();
return { easeFactor: ef, intervalDays: iv, repetitions: n, dueAt: due };
}
/* ── GET /api/flashcards/decks ─────────────────────────────────────────── */
function listDecks(req, res) {
const uid = req.user.id;
const decks = db.prepare(`
SELECT d.*,
(SELECT COUNT(*) FROM flashcard_cards c WHERE c.deck_id = d.id) AS card_count,
(SELECT COUNT(*) FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = d.id AND (r.id IS NULL OR r.due_at <= datetime('now'))) AS due_count
FROM flashcard_decks d
WHERE d.user_id = ?
ORDER BY d.created_at DESC
`).all(uid, uid);
res.json({ decks });
}
/* ── POST /api/flashcards/decks ────────────────────────────────────────── */
function createDeck(req, res) {
const uid = req.user.id;
const { title, description = '', color = '#9B5DE5' } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
const r = db.prepare(
`INSERT INTO flashcard_decks (user_id, title, description, color) VALUES (?,?,?,?)`
).run(uid, title.trim(), description, color);
res.json({ id: r.lastInsertRowid, title: title.trim(), description, color, card_count: 0, due_count: 0 });
}
/* ── PUT /api/flashcards/decks/:id ─────────────────────────────────────── */
function updateDeck(req, res) {
const uid = req.user.id;
const { title, description, color } = req.body;
const deck = db.prepare(`SELECT * FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
db.prepare(`UPDATE flashcard_decks SET title=?, description=?, color=? WHERE id=?`)
.run(title ?? deck.title, description ?? deck.description, color ?? deck.color, deck.id);
res.json({ ok: true });
}
/* ── DELETE /api/flashcards/decks/:id ──────────────────────────────────── */
function deleteDeck(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
db.prepare(`DELETE FROM flashcard_decks WHERE id = ?`).run(deck.id);
res.json({ ok: true });
}
/* ── GET /api/flashcards/decks/:id/cards ───────────────────────────────── */
function getCards(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
const cards = db.prepare(`
SELECT c.*, r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed
FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = ?
ORDER BY c.order_idx, c.id
`).all(uid, deck.id);
res.json({ cards });
}
/* ── POST /api/flashcards/decks/:id/cards ──────────────────────────────── */
function addCard(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
const { front = '', back = '' } = req.body;
const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`)
.get(deck.id)?.m ?? -1;
const r = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`)
.run(deck.id, front, back, maxIdx + 1);
res.json({ id: r.lastInsertRowid, deck_id: deck.id, front, back, order_idx: maxIdx + 1 });
}
/* ── POST /api/flashcards/decks/:id/cards/bulk ──────────────────────────── */
function addCardsBulk(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
const { cards } = req.body;
if (!Array.isArray(cards) || !cards.length) return res.status(400).json({ error: 'cards[] required' });
const maxIdx = db.prepare(`SELECT MAX(order_idx) AS m FROM flashcard_cards WHERE deck_id = ?`)
.get(deck.id)?.m ?? -1;
const stmt = db.prepare(`INSERT INTO flashcard_cards (deck_id, front, back, order_idx) VALUES (?,?,?,?)`);
const inserted = [];
const ins = db.transaction(() => {
cards.forEach((c, i) => {
const r = stmt.run(deck.id, c.front || '', c.back || '', maxIdx + 1 + i);
inserted.push({ id: r.lastInsertRowid, front: c.front, back: c.back });
});
});
ins();
res.json({ inserted });
}
/* ── PUT /api/flashcards/cards/:id ─────────────────────────────────────── */
function updateCard(req, res) {
const uid = req.user.id;
const card = db.prepare(`
SELECT c.* FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id
WHERE c.id = ? AND d.user_id = ?
`).get(req.params.id, uid);
if (!card) return res.status(404).json({ error: 'Not found' });
const { front, back } = req.body;
db.prepare(`UPDATE flashcard_cards SET front=?, back=? WHERE id=?`)
.run(front ?? card.front, back ?? card.back, card.id);
res.json({ ok: true });
}
/* ── DELETE /api/flashcards/cards/:id ──────────────────────────────────── */
function deleteCard(req, res) {
const uid = req.user.id;
const card = db.prepare(`
SELECT c.id FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id
WHERE c.id = ? AND d.user_id = ?
`).get(req.params.id, uid);
if (!card) return res.status(404).json({ error: 'Not found' });
db.prepare(`DELETE FROM flashcard_cards WHERE id = ?`).run(card.id);
res.json({ ok: true });
}
/* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */
function getStudySession(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
// due cards first, then new cards (no review yet), limit 20
const cards = db.prepare(`
SELECT c.id, c.front, c.back,
COALESCE(r.ease_factor, 2.5) AS ease_factor,
COALESCE(r.interval_days, 1) AS interval_days,
COALESCE(r.repetitions, 0) AS repetitions,
COALESCE(r.due_at, datetime('now')) AS due_at,
r.last_reviewed,
CASE WHEN r.id IS NULL THEN 0 ELSE 1 END AS seen
FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = ?
AND (r.id IS NULL OR r.due_at <= datetime('now'))
ORDER BY seen ASC, r.due_at ASC
LIMIT 20
`).all(uid, deck.id);
const total_due = db.prepare(`
SELECT COUNT(*) AS n FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now'))
`).get(uid, deck.id).n;
res.json({ cards, total_due });
}
/* ── POST /api/flashcards/cards/:id/review ─────────────────────────────── */
function submitReview(req, res) {
const uid = req.user.id;
const { quality } = req.body; // 0..5
if (quality === undefined || quality < 0 || quality > 5)
return res.status(400).json({ error: 'quality 0-5 required' });
const card = db.prepare(`
SELECT c.id FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id
WHERE c.id = ? AND d.user_id = ?
`).get(req.params.id, uid);
if (!card) return res.status(404).json({ error: 'Not found' });
const existing = db.prepare(
`SELECT * FROM flashcard_reviews WHERE user_id = ? AND card_id = ?`
).get(uid, card.id);
const prev = existing || { ease_factor: 2.5, interval_days: 1, repetitions: 0 };
const next = sm2(prev.ease_factor, prev.interval_days, prev.repetitions, quality);
if (existing) {
db.prepare(`
UPDATE flashcard_reviews
SET ease_factor=?, interval_days=?, repetitions=?, due_at=?, last_reviewed=datetime('now')
WHERE user_id=? AND card_id=?
`).run(next.easeFactor, next.intervalDays, next.repetitions, next.dueAt, uid, card.id);
} else {
db.prepare(`
INSERT INTO flashcard_reviews (user_id, card_id, ease_factor, interval_days, repetitions, due_at, last_reviewed)
VALUES (?,?,?,?,?,?,datetime('now'))
`).run(uid, card.id, next.easeFactor, next.intervalDays, next.repetitions, next.dueAt);
}
res.json({ ok: true, next_review: next.dueAt, interval_days: next.intervalDays });
}
/* ── GET /api/flashcards/stats ─────────────────────────────────────────── */
function getStats(req, res) {
const uid = req.user.id;
const decks_count = db.prepare(`SELECT COUNT(*) AS n FROM flashcard_decks WHERE user_id=?`).get(uid).n;
const cards_count = db.prepare(`
SELECT COUNT(*) AS n FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id WHERE d.user_id=?
`).get(uid).n;
const due_count = db.prepare(`
SELECT COUNT(*) AS n FROM flashcard_cards c
JOIN flashcard_decks d ON d.id = c.deck_id
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE d.user_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now'))
`).get(uid, uid).n;
const reviewed_today = db.prepare(`
SELECT COUNT(*) AS n FROM flashcard_reviews
WHERE user_id = ? AND date(last_reviewed) = date('now')
`).get(uid).n;
res.json({ decks_count, cards_count, due_count, reviewed_today });
}
module.exports = {
listDecks, createDeck, updateDeck, deleteDeck,
getCards, addCard, addCardsBulk, updateCard, deleteCard,
getStudySession, submitReview, getStats,
};
+310
View File
@@ -0,0 +1,310 @@
'use strict';
const db = require('../db/db');
const { awardXP } = require('./gamificationController');
/* ── Crossword generator (improved) ──────────────────────────────────── */
// Algorithm based on MichaelWehar/crossword-layout-generator scoring with additions:
// 1. Enumerate candidates from already-placed words (only perpendicular directions)
// 2. Weighted score: connections 70% + center 15% + orientation balance 10% + length 5%
// 3. Two-pass: retry skipped words after full first pass
// 4. Random pick from top-3 candidates per attempt for variety
// 5. 120 attempts, keeping best result by placed-word count
const CW_GRID = 20;
const CW_MAX_WORDS = 10;
const CW_ATTEMPTS = 120;
function _shuffle(arr) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
function _cwPlace(grid, word, row, col, dir) {
for (let i = 0; i < word.length; i++) {
if (dir === 'across') grid[row][col + i] = word[i];
else grid[row + i][col] = word[i];
}
}
// Returns number of valid intersections (≥0) if placement is valid, -1 if invalid
function _cwCheck(grid, word, row, col, dir, N) {
if (row < 0 || col < 0) return -1;
const endR = dir === 'down' ? row + word.length - 1 : row;
const endC = dir === 'across' ? col + word.length - 1 : col;
if (endR >= N || endC >= N) return -1;
// No letter immediately before/after word in its own direction
if (dir === 'across') {
if (col > 0 && grid[row][col - 1]) return -1;
if (endC < N - 1 && grid[row][endC + 1]) return -1;
} else {
if (row > 0 && grid[row - 1][col]) return -1;
if (endR < N - 1 && grid[endR + 1][col]) return -1;
}
let intersections = 0;
for (let i = 0; i < word.length; i++) {
const r = dir === 'across' ? row : row + i;
const c = dir === 'across' ? col + i : col;
const ex = grid[r][c];
if (ex !== null) {
if (ex !== word[i]) return -1; // letter conflict
intersections++;
} else {
// Empty cell — perpendicular neighbours must be empty
if (dir === 'across') {
if (r > 0 && grid[r - 1][c]) return -1;
if (r < N - 1 && grid[r + 1][c]) return -1;
} else {
if (c > 0 && grid[r][c - 1]) return -1;
if (c < N - 1 && grid[r][c + 1]) return -1;
}
}
}
return intersections;
}
// Find all valid placements by iterating over already-placed words.
// Only tries the direction PERPENDICULAR to each placed word — guaranteed crossing.
function _cwFindPlacements(grid, word, placed, N, center) {
const seen = new Set();
const candidates = [];
const acrossCount = placed.filter(p => p.dir === 'across').length;
const downCount = placed.length - acrossCount;
for (const pw of placed) {
const newDir = pw.dir === 'across' ? 'down' : 'across';
for (let wi = 0; wi < word.length; wi++) {
for (let pi = 0; pi < pw.word.length; pi++) {
if (word[wi] !== pw.word[pi]) continue;
// Intersection cell on the grid
const ir = pw.dir === 'across' ? pw.row : pw.row + pi;
const ic = pw.dir === 'across' ? pw.col + pi : pw.col;
// Start of new word so that letter wi lands at (ir, ic)
const r = newDir === 'across' ? ir : ir - wi;
const c = newDir === 'across' ? ic - wi : ic;
const key = `${r},${c},${newDir}`;
if (seen.has(key)) continue;
seen.add(key);
const intersections = _cwCheck(grid, word, r, c, newDir, N);
if (intersections < 1) continue;
// Weighted score (MichaelWehar 70/15/10/5 split)
const maxDist = center * Math.SQRT2;
const dist = Math.hypot(r - center, c - center);
const conn = intersections / (word.length / 2);
const cen = 1 - dist / maxDist;
const oBal = newDir === 'down'
? (acrossCount >= downCount ? 0.1 : 0)
: (downCount >= acrossCount ? 0.1 : 0);
const len = word.length / N;
const score = conn * 0.7 + cen * 0.15 + oBal * 0.1 + len * 0.05;
candidates.push({ row: r, col: c, dir: newDir, intersections, score });
}
}
}
return candidates;
}
function _buildAttempt(words, N) {
const grid = Array.from({ length: N }, () => Array(N).fill(null));
const placed = [];
const center = Math.floor(N / 2);
// First word: placed across at center
const first = words[0];
const r0 = center;
const c0 = Math.max(1, center - Math.floor(first.word.length / 2));
if (c0 + first.word.length > N - 1) return { placed, grid };
_cwPlace(grid, first.word, r0, c0, 'across');
placed.push({ ...first, row: r0, col: c0, dir: 'across' });
const skipped = [];
for (const pass of [words.slice(1), skipped]) {
for (const w of pass) {
if (placed.length >= CW_MAX_WORDS) break;
const cands = _cwFindPlacements(grid, w.word, placed, N, center);
if (!cands.length) {
if (pass !== skipped) skipped.push(w);
continue;
}
cands.sort((a, b) => b.score - a.score);
// Pick randomly from top 3 for variety across attempts
const pick = cands[Math.floor(Math.random() * Math.min(3, cands.length))];
_cwPlace(grid, w.word, pick.row, pick.col, pick.dir);
placed.push({ ...w, row: pick.row, col: pick.col, dir: pick.dir });
}
}
return { placed, grid };
}
function buildCrossword(wordList) {
const N = CW_GRID;
let best = null;
// Baseline: longest words first
const sorted = [...wordList].sort((a, b) => b.word.length - a.word.length);
best = _buildAttempt(sorted, N);
for (let a = 1; a < CW_ATTEMPTS; a++) {
if (best.placed.length >= CW_MAX_WORDS) break;
const shuffled = _shuffle(wordList);
// Ensure a long word is near the front (better first placement)
shuffled.sort((x, y) => (y.word.length >= 6 ? 1 : 0) - (x.word.length >= 6 ? 1 : 0));
const attempt = _buildAttempt(shuffled, N);
if (attempt.placed.length > best.placed.length) best = attempt;
}
if (!best || best.placed.length < 3) return null;
// Compact: trim empty rows/cols
const { placed, grid } = best;
let minR = N, maxR = 0, minC = N, maxC = 0;
for (let r = 0; r < N; r++)
for (let c = 0; c < N; c++)
if (grid[r][c]) {
minR = Math.min(minR, r); maxR = Math.max(maxR, r);
minC = Math.min(minC, c); maxC = Math.max(maxC, c);
}
const trimmed = [];
for (let r = minR; r <= maxR; r++) trimmed.push(grid[r].slice(minC, maxC + 1));
const words = placed.map(p => ({
word: p.word, clue: p.clue,
subjectName: p.subjectName,
row: p.row - minR, col: p.col - minC, dir: p.dir,
}));
// Number words in reading order (top→bottom, left→right)
words.sort((a, b) => a.row !== b.row ? a.row - b.row : a.col - b.col);
const starts = new Map();
let num = 1;
for (const w of words) {
const key = `${w.row},${w.col}`;
if (!starts.has(key)) starts.set(key, num++);
w.num = starts.get(key);
}
return { grid: trimmed, words, across: words.filter(w => w.dir === 'across'), down: words.filter(w => w.dir === 'down') };
}
/* ── GET /api/games/hangman/word?subject_slug=bio ─────────────────────── */
function hangmanWord(req, res) {
const { subject_slug } = req.query;
let row;
if (subject_slug) {
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subj) return res.status(404).json({ error: 'Subject not found' });
row = db.prepare(`
SELECT t.id, t.name, s.name AS subject_name, s.slug AS subject_slug
FROM topics t
JOIN subjects s ON s.id = t.subject_id
WHERE t.subject_id = ? AND length(t.name) >= 4
ORDER BY RANDOM() LIMIT 1
`).get(subj.id);
} else {
row = db.prepare(`
SELECT t.id, t.name, s.name AS subject_name, s.slug AS subject_slug
FROM topics t
JOIN subjects s ON s.id = t.subject_id
WHERE length(t.name) >= 4
ORDER BY RANDOM() LIMIT 1
`).get();
}
if (!row) return res.status(404).json({ error: 'No topics found' });
res.json({
topicId: row.id,
word: row.name.toUpperCase(),
hint: row.subject_name,
subjectSlug: row.subject_slug,
});
}
/* ── POST /api/games/hangman/complete ─────────────────────────────────── */
function hangmanComplete(req, res) {
const { won, errors } = req.body;
if (typeof won !== 'boolean') return res.status(400).json({ error: 'won required' });
let xpGain = 0;
if (won) {
// 15 XP perfect, -2 per error, min 5
xpGain = Math.max(5, 15 - (Number(errors) || 0) * 2);
}
if (xpGain > 0) {
try { awardXP(req.user.id, xpGain, 'hangman_win'); } catch (e) { console.error('[games] hangman XP:', e.message); }
}
res.json({ ok: true, xp: xpGain });
}
/* ── GET /api/games/crossword/generate?subject_slug= ──────────────────── */
function crosswordGenerate(req, res) {
const { subject_slug } = req.query;
let rows;
const base = `
SELECT t.id, t.name, s.name AS subject_name,
(SELECT q.text FROM questions q WHERE q.topic_id = t.id ORDER BY RANDOM() LIMIT 1) AS clue
FROM topics t
JOIN subjects s ON s.id = t.subject_id
WHERE length(t.name) BETWEEN 4 AND 12
AND t.name NOT LIKE '% %'
AND t.name NOT GLOB '*[0-9]*'
`;
if (subject_slug) {
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subj) return res.status(404).json({ error: 'Subject not found' });
rows = db.prepare(base + ' AND t.subject_id = ? ORDER BY RANDOM() LIMIT 50').all(subj.id);
} else {
rows = db.prepare(base + ' ORDER BY RANDOM() LIMIT 50').all();
}
if (rows.length < 3) return res.status(404).json({ error: 'Not enough topics for a crossword' });
const wordList = rows
.filter(r => /^[А-яЁёA-Za-z]+$/.test(r.name)) // only letters, no numbers/symbols
.map(r => ({
word: r.name.toUpperCase(),
clue: r.clue || r.subject_name,
subjectName: r.subject_name,
}));
const crossword = buildCrossword(wordList);
if (!crossword) return res.status(404).json({ error: 'Could not build crossword' });
res.json(crossword);
}
/* ── POST /api/games/crossword/complete ───────────────────────────────── */
function crosswordComplete(req, res) {
const { completed, hintsUsed } = req.body;
if (typeof completed !== 'boolean') return res.status(400).json({ error: 'completed required' });
let xpGain = 0;
if (completed) {
xpGain = Math.max(5, 20 - (Number(hintsUsed) || 0) * 3);
}
if (xpGain > 0) {
try { awardXP(req.user.id, xpGain, 'crossword_win'); } catch (e) { console.error('[games] crossword XP:', e.message); }
}
res.json({ ok: true, xp: xpGain });
}
module.exports = { hangmanWord, hangmanComplete, crosswordGenerate, crosswordComplete };
@@ -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,
};
@@ -0,0 +1,65 @@
'use strict';
const db = require('../db/db');
/* ── GET /api/knowledge-map?subject_slug=bio ──────────────────────────── */
function getMap(req, res) {
const { subject_slug } = req.query;
const uid = req.user.id;
let subjects;
if (subject_slug) {
subjects = db.prepare('SELECT id, slug, name, icon FROM subjects WHERE slug = ?').all(subject_slug);
} else {
subjects = db.prepare('SELECT id, slug, name, icon FROM subjects ORDER BY name').all();
}
const nodes = [];
const links = [];
for (const subj of subjects) {
nodes.push({
id: `subj_${subj.id}`,
type: 'subject',
label: subj.name,
icon: subj.icon,
slug: subj.slug,
mastery: null,
});
const topics = db.prepare(`
SELECT t.id, t.name,
COUNT(DISTINCT q.id) AS total_q,
COUNT(DISTINCT CASE WHEN ua.is_correct = 1 THEN ua.question_id END) AS correct_q
FROM topics t
LEFT JOIN questions q ON q.topic_id = t.id
LEFT JOIN user_answers ua ON ua.question_id = q.id
AND ua.session_id IN (
SELECT id FROM test_sessions WHERE user_id = ? AND status = 'completed'
)
WHERE t.subject_id = ?
GROUP BY t.id
ORDER BY t.order_index
`).all(uid, subj.id);
for (const topic of topics) {
const mastery = topic.total_q > 0
? Math.round((topic.correct_q / topic.total_q) * 100)
: null;
nodes.push({
id: `topic_${topic.id}`,
type: 'topic',
label: topic.name,
mastery,
totalQ: topic.total_q,
correctQ: topic.correct_q,
});
links.push({ source: `subj_${subj.id}`, target: `topic_${topic.id}` });
}
}
res.json({ nodes, links });
}
module.exports = { getMap };
+296
View File
@@ -0,0 +1,296 @@
const db = require('../db/db');
const { onLessonComplete } = require('./gamificationController');
/* ── helpers ──────────────────────────────────────────────────────────── */
function parseBlock(b) {
let data = {};
try { data = JSON.parse(b.data); } catch {}
return { id: b.id, type: b.type, orderIndex: b.order_index, data };
}
// Estimate read time from blocks (words / 200 wpm)
// Accepts blocks with data already parsed as objects (not JSON strings)
function calcReadTime(blocks) {
let words = 0;
for (const b of blocks) {
const d = (typeof b.data === 'string') ? (() => { try { return JSON.parse(b.data); } catch { return {}; } })() : (b.data || {});
if (b.type === 'text') words += (d.html || d.text || '').replace(/<[^>]+>/g, '').split(/\s+/).filter(Boolean).length;
if (b.type === 'heading') words += (d.text || '').split(/\s+/).filter(Boolean).length;
if (b.type === 'quiz') words += 8;
if (b.type === 'accordion') words += (d.content || '').split(/\s+/).filter(Boolean).length;
if (b.type === 'timeline' && Array.isArray(d.items))
for (const it of d.items) words += ((it.title||'') + ' ' + (it.text||'')).split(/\s+/).filter(Boolean).length;
if (b.type === 'geogebra' || b.type === 'diagram') words += 15;
if (b.type === 'audio') words += 30;
if (b.type === 'video') words += 30;
if (b.type === 'alert') words += (d.text || '').split(/\s+/).filter(Boolean).length;
if (b.type === 'columns' && Array.isArray(d.cols))
for (const col of d.cols) words += (col.content || '').replace(/<[^>]+>/g, '').split(/\s+/).filter(Boolean).length;
}
return Math.max(1, Math.ceil(words / 200));
}
/* ── GET /api/lessons/:id ─────────────────────────────────────────────── */
function get(req, res) {
const role = req.user.role;
const uid = req.user.id;
const row = db.prepare(`
SELECT l.*, c.title AS course_title, c.is_published AS course_published
FROM lessons l JOIN courses c ON c.id = l.course_id
WHERE l.id = ?
`).get(req.params.id);
if (!row) return res.status(404).json({ error: 'Lesson not found' });
if (role === 'student' && !row.is_published)
return res.status(403).json({ error: 'Lesson not published' });
if (role === 'student' && !row.course_published)
return res.status(403).json({ error: 'Course not published' });
const lesson = row;
const course = { title: row.course_title };
const blocks = db.prepare(
'SELECT id, type, order_index, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
).all(lesson.id).map(parseBlock);
const progress = db.prepare(
'SELECT completed FROM lesson_progress WHERE user_id = ? AND lesson_id = ?'
).get(uid, lesson.id);
const note = db.prepare(
'SELECT text FROM lesson_notes WHERE user_id = ? AND lesson_id = ?'
).get(uid, lesson.id);
// adjacent lessons
const pubWhere = role === 'student' ? 'AND is_published = 1' : '';
const siblings = db.prepare(`
SELECT id, title FROM lessons WHERE course_id = ? ${pubWhere} ORDER BY order_index, id
`).all(lesson.course_id);
const idx = siblings.findIndex(s => s.id === lesson.id);
const prev = idx > 0 ? siblings[idx - 1] : null;
const next = idx >= 0 && idx < siblings.length - 1 ? siblings[idx + 1] : null;
res.json({
id: lesson.id,
courseId: lesson.course_id,
courseTitle: course.title,
title: lesson.title,
orderIndex: lesson.order_index,
isPublished: lesson.is_published === 1,
sectionId: lesson.section_id,
readTime: lesson.read_time || 0,
blocks,
completed: progress?.completed === 1,
note: note?.text || '',
prev: prev ? { id: prev.id, title: prev.title } : null,
next: next ? { id: next.id, title: next.title } : null,
});
}
/* ── POST /api/lessons ────────────────────────────────────────────────── */
function create(req, res) {
const { courseId, title, orderIndex, sectionId } = req.body;
if (!courseId || !title)
return res.status(400).json({ error: 'courseId and title required' });
if (!db.prepare('SELECT id FROM courses WHERE id = ?').get(courseId))
return res.status(404).json({ error: 'Course not found' });
const r = db.prepare(
'INSERT INTO lessons (course_id, title, order_index, section_id) VALUES (?, ?, ?, ?)'
).run(courseId, title.trim(), orderIndex ?? 0, sectionId || null);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── PUT /api/lessons/:id ─────────────────────────────────────────────── */
function update(req, res) {
const lesson = db.prepare(`
SELECT l.*, c.created_by AS course_owner
FROM lessons l JOIN courses c ON c.id = l.course_id WHERE l.id = ?
`).get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
if (req.user.role !== 'admin' && lesson.course_owner !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const { title, orderIndex, isPublished, sectionId } = req.body;
db.prepare(`
UPDATE lessons SET title=?, order_index=?, is_published=?, section_id=? WHERE id=?
`).run(
title ?? lesson.title,
orderIndex ?? lesson.order_index,
isPublished !== undefined ? (isPublished ? 1 : 0) : lesson.is_published,
sectionId !== undefined ? (sectionId || null) : lesson.section_id,
lesson.id
);
res.json({ ok: true });
}
/* ── DELETE /api/lessons/:id ──────────────────────────────────────────── */
function remove(req, res) {
const lesson = db.prepare(`
SELECT l.id, c.created_by AS course_owner
FROM lessons l JOIN courses c ON c.id = l.course_id WHERE l.id = ?
`).get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
if (req.user.role !== 'admin' && lesson.course_owner !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM lessons WHERE id = ?').run(lesson.id);
res.json({ ok: true });
}
/* ── PUT /api/lessons/:id/blocks ──────────────────────────────────────── */
function saveBlocks(req, res) {
const lesson = db.prepare(`
SELECT l.id, c.created_by AS course_owner
FROM lessons l JOIN courses c ON c.id = l.course_id
WHERE l.id = ?
`).get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
if (req.user.role !== 'admin' && lesson.course_owner !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const blocks = req.body.blocks;
if (!Array.isArray(blocks))
return res.status(400).json({ error: 'blocks must be an array' });
const VALID_TYPES = ['heading','text','formula','image','quiz','sim','table','code','divider','callout','video','flashcard','matching','fill-blank','ordering','accordion','timeline','diagram','geogebra','audio','columns','alert'];
db.transaction(() => {
db.prepare('DELETE FROM lesson_blocks WHERE lesson_id = ?').run(lesson.id);
const ins = db.prepare(
'INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)'
);
blocks.forEach((b, i) => {
const type = VALID_TYPES.includes(b.type) ? b.type : 'text';
ins.run(lesson.id, type, b.orderIndex ?? i, JSON.stringify(b.data || {}));
});
// recalculate read time — pass already-parsed data objects, no double stringify/parse
const rt = calcReadTime(blocks);
db.prepare('UPDATE lessons SET read_time = ? WHERE id = ?').run(rt, lesson.id);
})();
res.json({ ok: true, count: blocks.length });
}
/* ── POST /api/lessons/:id/complete ──────────────────────────────────── */
function markComplete(req, res) {
const lesson = db.prepare('SELECT * FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
db.prepare(`
INSERT INTO lesson_progress (user_id, lesson_id, completed, updated_at)
VALUES (?, ?, 1, datetime('now'))
ON CONFLICT (user_id, lesson_id) DO UPDATE SET completed=1, updated_at=datetime('now')
`).run(req.user.id, lesson.id);
const total = db.prepare(
'SELECT COUNT(*) AS n FROM lessons WHERE course_id = ? AND is_published = 1'
).get(lesson.course_id).n;
const done = 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(lesson.course_id, req.user.id).n;
try { onLessonComplete(req.user.id, lesson.course_id); } catch {}
res.json({ ok: true, courseComplete: done >= total && total > 0 });
}
/* ── PUT /api/lessons/:id/note ────────────────────────────────────────── */
function saveNote(req, res) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const text = (req.body.text || '').slice(0, 5000);
db.prepare(`
INSERT INTO lesson_notes (user_id, lesson_id, text, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT (user_id, lesson_id) DO UPDATE SET text=excluded.text, updated_at=datetime('now')
`).run(req.user.id, lesson.id, text);
res.json({ ok: true });
}
/* ── GET /api/lessons/:id/comments ──────────────────────────────────── */
function listComments(req, res) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const rows = db.prepare(`
SELECT c.id, c.lesson_id, c.user_id, c.parent_id, c.text, c.created_at,
u.name AS user_name, u.role AS user_role
FROM lesson_comments c
JOIN users u ON c.user_id = u.id
WHERE c.lesson_id = ?
ORDER BY c.created_at ASC
`).all(lesson.id);
// build threaded structure: top-level + replies
const top = [];
const byId = {};
rows.forEach(r => {
const item = {
id: r.id, lessonId: r.lesson_id, userId: r.user_id,
parentId: r.parent_id, text: r.text, createdAt: r.created_at,
userName: r.user_name, userRole: r.user_role,
replies: [],
};
byId[r.id] = item;
if (!r.parent_id) {
top.push(item);
} else if (byId[r.parent_id]) {
byId[r.parent_id].replies.push(item);
} else {
top.push(item); // orphan → treat as top-level
}
});
res.json(top);
}
/* ── POST /api/lessons/:id/comments ────────────────────────────────── */
function addComment(req, res) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const text = (req.body.text || '').trim();
if (!text) return res.status(400).json({ error: 'text required' });
if (text.length > 2000) return res.status(400).json({ error: 'Comment too long (max 2000)' });
const parentId = req.body.parentId || null;
if (parentId) {
const parent = db.prepare('SELECT id FROM lesson_comments WHERE id = ? AND lesson_id = ?').get(parentId, lesson.id);
if (!parent) return res.status(400).json({ error: 'Parent comment not found' });
}
const r = db.prepare(
'INSERT INTO lesson_comments (lesson_id, user_id, parent_id, text) VALUES (?, ?, ?, ?)'
).run(lesson.id, req.user.id, parentId, text);
// notify lesson author / teacher if comment is from student
try {
const course = db.prepare('SELECT created_by FROM courses WHERE id = (SELECT course_id FROM lessons WHERE id = ?)').get(lesson.id);
if (course && course.created_by !== req.user.id) {
const userName = db.prepare('SELECT name FROM users WHERE id = ?').get(req.user.id)?.name || 'Ученик';
const lessonTitle = db.prepare('SELECT title FROM lessons WHERE id = ?').get(lesson.id)?.title || 'Урок';
db.prepare(
'INSERT INTO notifications (user_id, type, message, link) VALUES (?, ?, ?, ?)'
).run(course.created_by, 'comment', `${userName} оставил(а) комментарий к уроку «${lessonTitle}»`,
`/lesson?id=${lesson.id}#comments`);
}
} catch {}
res.status(201).json({ id: Number(r.lastInsertRowid) });
}
/* ── DELETE /api/lessons/:id/comments/:cid ─────────────────────────── */
function deleteComment(req, res) {
const comment = db.prepare('SELECT * FROM lesson_comments WHERE id = ? AND lesson_id = ?').get(req.params.cid, req.params.id);
if (!comment) return res.status(404).json({ error: 'Comment not found' });
// only author or teacher/admin can delete
const isOwner = comment.user_id === req.user.id;
const isPrivileged = ['teacher', 'admin'].includes(req.user.role);
if (!isOwner && !isPrivileged) return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM lesson_comments WHERE id = ?').run(comment.id);
res.json({ ok: true });
}
module.exports = { get, create, update, remove, saveBlocks, markComplete, saveNote, listComments, addComment, deleteComment };
+197
View File
@@ -0,0 +1,197 @@
const db = require('../db/db');
const { emit, emitToClass } = require('../sse');
/* POST /api/live — teacher creates live session */
function create(req, res) {
const { class_id } = req.body;
const teacher = req.user;
if (!class_id) return res.status(400).json({ error: 'class_id required' });
const cls = teacher.role === 'admin'
? db.prepare('SELECT id, name FROM classes WHERE id=?').get(class_id)
: db.prepare('SELECT id, name FROM classes WHERE id=? AND teacher_id=?').get(class_id, teacher.id);
if (!cls) return res.status(403).json({ error: 'Forbidden' });
// End any active session for this class
db.prepare(`UPDATE live_sessions SET status='finished', ended_at=datetime('now')
WHERE class_id=? AND status IN ('waiting','active')`).run(class_id);
const { lastInsertRowid } = db.prepare(
`INSERT INTO live_sessions (class_id, teacher_id) VALUES (?,?)`
).run(class_id, teacher.id);
emitToClass(class_id, { type: 'live_started', liveId: lastInsertRowid, className: cls.name });
res.json(db.prepare('SELECT * FROM live_sessions WHERE id=?').get(lastInsertRowid));
}
/* PUT /api/live/:id/question — teacher sets next question */
function setQuestion(req, res) {
const liveId = Number(req.params.id);
const { question_id } = req.body;
const teacher = req.user;
const live = db.prepare('SELECT * FROM live_sessions WHERE id=?').get(liveId);
if (!live) return res.status(404).json({ error: 'Not found' });
if (live.teacher_id !== teacher.id && teacher.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM live_answers WHERE live_session_id=?').run(liveId);
db.prepare(`UPDATE live_sessions SET question_id=?, status='active', show_results=0 WHERE id=?`)
.run(question_id, liveId);
const q = db.prepare(
`SELECT q.id, q.text, q.type, q.difficulty, t.name AS topic
FROM questions q LEFT JOIN topics t ON t.id=q.topic_id WHERE q.id=?`
).get(question_id);
const options = db.prepare(
'SELECT id, text, order_index FROM options WHERE question_id=? ORDER BY order_index'
).all(question_id);
emitToClass(live.class_id, { type: 'live_question', liveId, question: { ...q, options } });
res.json({ ok: true, question: { ...q, options } });
}
/* POST /api/live/:id/answer — student submits answer */
function answer(req, res) {
const liveId = Number(req.params.id);
const { option_id, answer_text } = req.body;
const userId = req.user.id;
const live = db.prepare(`SELECT * FROM live_sessions WHERE id=? AND status='active'`).get(liveId);
if (!live) return res.status(404).json({ error: 'Сессия не активна' });
// Verify caller is a member of the class hosting this live session
const isMember = db.prepare(
'SELECT 1 FROM class_members WHERE class_id = ? AND user_id = ?'
).get(live.class_id, userId);
if (!isMember) return res.status(403).json({ error: 'Forbidden' });
const existing = db.prepare(
'SELECT id FROM live_answers WHERE live_session_id=? AND user_id=?'
).get(liveId, userId);
if (existing) return res.status(409).json({ error: 'Уже отвечено' });
let is_correct = null;
if (option_id) {
const opt = db.prepare(
'SELECT is_correct FROM options WHERE id=? AND question_id=?'
).get(option_id, live.question_id);
is_correct = opt ? opt.is_correct : 0;
}
db.prepare(
`INSERT INTO live_answers (live_session_id, user_id, option_id, answer_text, is_correct)
VALUES (?,?,?,?,?)`
).run(liveId, userId, option_id || null, answer_text || null, is_correct);
const { c: count } = db.prepare(
'SELECT COUNT(*) AS c FROM live_answers WHERE live_session_id=?'
).get(liveId);
emit(live.teacher_id, { type: 'live_answer_count', liveId, count });
res.json({ ok: true, is_correct });
}
/* GET /api/live/:id/results — teacher reveals & gets results */
function results(req, res) {
const liveId = Number(req.params.id);
const teacher = req.user;
const live = db.prepare('SELECT * FROM live_sessions WHERE id=?').get(liveId);
if (!live) return res.status(404).json({ error: 'Not found' });
if (live.teacher_id !== teacher.id && teacher.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
const question = db.prepare(
'SELECT id, text, explanation FROM questions WHERE id=?'
).get(live.question_id);
const options = db.prepare(`
SELECT o.id, o.text, o.is_correct,
COUNT(la.id) AS chosen_count
FROM options o
LEFT JOIN live_answers la ON la.option_id=o.id AND la.live_session_id=?
WHERE o.question_id=?
GROUP BY o.id ORDER BY o.order_index
`).all(liveId, live.question_id);
const stats = db.prepare(
`SELECT COUNT(*) AS total, SUM(is_correct) AS correct
FROM live_answers WHERE live_session_id=?`
).get(liveId);
db.prepare('UPDATE live_sessions SET show_results=1 WHERE id=?').run(liveId);
emitToClass(live.class_id, { type: 'live_results', liveId, question, options, stats });
res.json({ question, options, stats });
}
/* DELETE /api/live/:id — teacher ends session */
function end(req, res) {
const liveId = Number(req.params.id);
const teacher = req.user;
const live = db.prepare('SELECT * FROM live_sessions WHERE id=?').get(liveId);
if (!live) return res.status(404).json({ error: 'Not found' });
if (live.teacher_id !== teacher.id && teacher.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
db.prepare(`UPDATE live_sessions SET status='finished', ended_at=datetime('now') WHERE id=?`).run(liveId);
emitToClass(live.class_id, { type: 'live_ended', liveId });
res.json({ ok: true });
}
/* GET /api/live/class/:classId/active — student polls active session */
function getActive(req, res) {
const classId = Number(req.params.classId);
// Only class members (or teachers/admins) may poll
if (!['teacher', 'admin'].includes(req.user.role)) {
const isMember = db.prepare(
'SELECT 1 FROM class_members WHERE class_id = ? AND user_id = ?'
).get(classId, req.user.id);
if (!isMember) return res.status(403).json({ error: 'Forbidden' });
}
const live = db.prepare(`
SELECT * FROM live_sessions WHERE class_id=? AND status IN ('waiting','active')
ORDER BY id DESC LIMIT 1
`).get(classId);
if (!live) return res.json({ active: false });
let question = null, options = null, myAnswer = null;
if (live.question_id && live.status === 'active') {
question = db.prepare('SELECT id, text, type FROM questions WHERE id=?').get(live.question_id);
options = db.prepare(
'SELECT id, text, order_index FROM options WHERE question_id=? ORDER BY order_index'
).all(live.question_id);
myAnswer = db.prepare(
'SELECT option_id, is_correct FROM live_answers WHERE live_session_id=? AND user_id=?'
).get(live.id, req.user.id);
}
res.json({ active: true, live, question, options, myAnswer, showResults: live.show_results });
}
/* GET /api/live/:id — get session info (teacher) */
function getSession(req, res) {
const liveId = Number(req.params.id);
const teacher = req.user;
const live = db.prepare('SELECT * FROM live_sessions WHERE id=?').get(liveId);
if (!live) return res.status(404).json({ error: 'Not found' });
if (live.teacher_id !== teacher.id && teacher.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
const answerCount = db.prepare(
'SELECT COUNT(*) AS c FROM live_answers WHERE live_session_id=?'
).get(liveId).c;
const memberCount = db.prepare(
'SELECT COUNT(*) AS c FROM class_members WHERE class_id=?'
).get(live.class_id).c;
res.json({ ...live, answerCount, memberCount });
}
module.exports = { create, setQuestion, answer, results, end, getActive, getSession };
@@ -0,0 +1,66 @@
const jwt = require('jsonwebtoken');
const db = require('../db/db');
const { addClient, removeClient } = require('../sse');
const _stmts = {
list: db.prepare('SELECT id, type, message, link, is_read, created_at FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50'),
markOne: db.prepare('UPDATE notifications SET is_read = 1 WHERE id = ? AND user_id = ?'),
markAll: db.prepare('UPDATE notifications SET is_read = 1 WHERE user_id = ?'),
getUser: db.prepare('SELECT id, token_version, is_banned FROM users WHERE id = ?'),
};
/* ── GET /api/notifications ─────────────────────────────────────────────── */
function list(req, res) {
const rows = _stmts.list.all(req.user.id);
const unread = rows.filter(r => !r.is_read).length;
res.json({ notifications: rows, unread });
}
/* ── PATCH /api/notifications/:id/read ──────────────────────────────────── */
function markRead(req, res) {
_stmts.markOne.run(req.params.id, req.user.id);
res.json({ ok: true });
}
/* ── POST /api/notifications/read-all ───────────────────────────────────── */
function markAllRead(req, res) {
_stmts.markAll.run(req.user.id);
res.json({ ok: true });
}
/* ── GET /api/notifications/stream ── SSE (auth via ?token=JWT) ─────────── */
function stream(req, res) {
const token = req.query.token;
if (!token) return res.status(401).end();
let userId;
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
const fresh = _stmts.getUser.get(payload.id);
if (!fresh) return res.status(401).end();
if (fresh.is_banned) return res.status(403).end();
if (fresh.token_version != null && payload.tv !== fresh.token_version) return res.status(401).end();
userId = payload.id;
} catch {
return res.status(401).end();
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.setHeader('Referrer-Policy', 'no-referrer');
res.flushHeaders();
addClient(userId, res);
res.write(`data: ${JSON.stringify({ type: 'connected' })}\n\n`);
const hb = setInterval(() => { try { res.write(':hb\n\n'); } catch {} }, 25_000);
req.on('close', () => {
clearInterval(hb);
removeClient(userId, res);
});
}
module.exports = { list, markRead, markAllRead, stream };
+348
View File
@@ -0,0 +1,348 @@
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const db = require('../db/db');
const { JWT_SECRET } = require('../config');
/* ── Prepared statements ────────────────────────────────────────────── */
const stmts = {
linkByToken: db.prepare('SELECT * FROM parent_links WHERE token = ?'),
linkById: db.prepare('SELECT * FROM parent_links WHERE id = ?'),
linksByStudent: db.prepare('SELECT id, token, label, is_active, last_used, created_at, expires_at FROM parent_links WHERE student_id = ? ORDER BY created_at DESC'),
linkCount: db.prepare('SELECT COUNT(*) AS cnt FROM parent_links WHERE student_id = ?'),
insertLink: db.prepare('INSERT INTO parent_links (student_id, token, label) VALUES (?, ?, ?)'),
updateLink: db.prepare('UPDATE parent_links SET label = ?, is_active = ? WHERE id = ?'),
deleteLink: db.prepare('DELETE FROM parent_links WHERE id = ?'),
updateLastUsed: db.prepare("UPDATE parent_links SET last_used = datetime('now') WHERE id = ?"),
/* Mega CTE: student info + stats + heatmap + weekly + recent activity in ONE query */
dashboardMega: db.prepare(`
WITH base AS (
SELECT ts.id, ts.score, ts.total, ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = @uid AND ts.status = 'completed'
),
hm AS (
SELECT date(ts.started_at) AS day, COUNT(*) AS cnt
FROM test_sessions ts
WHERE ts.user_id = @uid AND ts.started_at >= date('now', '-90 days')
GROUP BY day ORDER BY day
),
week_activity AS (
SELECT COUNT(*) AS cnt,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct
FROM base WHERE started_at >= date('now', 'weekday 0', '-7 days')
)
SELECT
(SELECT json_object('name',u.name,'xp',u.xp,'level',u.level,
'streak_current',u.streak_current,'streak_best',u.streak_best,'coins',u.coins)
FROM users u WHERE u.id = @uid) AS student,
(SELECT COALESCE(json_group_array(json_object(
'week', week, 'sessions', sessions, 'avg_pct', avg_pct)), '[]')
FROM (SELECT strftime('%Y-%W', finished_at) AS week,
COUNT(*) AS sessions,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct
FROM base WHERE finished_at >= date('now', '-56 days')
GROUP BY week ORDER BY week)) AS weekly,
(SELECT COALESCE(json_group_array(json_object('day', day, 'count', cnt)), '[]')
FROM hm) AS heatmap,
(SELECT COALESCE(json_group_array(json_object(
'slug', subject_slug, 'name', subject_name,
'sessions', sessions, 'avg_pct', avg_pct)), '[]')
FROM (SELECT subject_slug, subject_name, COUNT(*) AS sessions,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct
FROM base GROUP BY subject_slug ORDER BY sessions DESC)) AS bySubject,
(SELECT COALESCE(json_group_array(json_object(
'id', id, 'score', score, 'total', total,
'finished_at', finished_at, 'subject_slug', subject_slug)), '[]')
FROM (SELECT id, score, total, finished_at, subject_slug
FROM base ORDER BY finished_at ASC LIMIT 20)) AS trend,
(SELECT json_object(
'sessions', COUNT(*),
'correct', COALESCE(SUM(score), 0),
'questions',COALESCE(SUM(total), 0),
'avg_pct', COALESCE(AVG(CASE WHEN total>0 THEN score*100.0/total END), 0))
FROM base) AS totals,
(SELECT MAX(started_at) FROM test_sessions WHERE user_id = @uid) AS lastSessionDate,
(SELECT json_object('cnt', cnt, 'avg_pct', avg_pct) FROM week_activity) AS weekActivity
`),
weakTopics: db.prepare(`
SELECT t.name AS topic, s.name AS subject_name,
COUNT(ua.id) AS total,
SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS wrong,
ROUND(CAST(SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS REAL)
/ COUNT(ua.id) * 100, 0) AS error_pct
FROM user_answers ua
JOIN test_sessions ts ON ts.id = ua.session_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 = q.subject_id
WHERE ts.user_id = ? AND ts.status = 'completed' AND q.topic_id IS NOT NULL
GROUP BY q.topic_id HAVING total >= 2
ORDER BY error_pct DESC, wrong DESC LIMIT 5
`),
courseProgress: db.prepare(`
SELECT c.id, c.title, c.cover_emoji, c.subject_slug,
COUNT(l.id) AS total_lessons, COUNT(lp.id) AS done_lessons
FROM courses c JOIN lessons l ON l.course_id = c.id AND l.is_published = 1
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = @uid
WHERE c.is_published = 1 GROUP BY c.id HAVING done_lessons > 0
ORDER BY done_lessons * 1.0 / total_lessons DESC LIMIT 10
`),
upcomingDeadlines: db.prepare(`
SELECT a.id, a.title, a.deadline, a.subject_slug,
(SELECT COUNT(*) FROM assignment_sessions ases
JOIN test_sessions ts ON ts.id = ases.session_id
WHERE ases.assignment_id = a.id AND ts.user_id = ? AND ts.status = 'completed') AS done
FROM assignments a
JOIN class_members cm ON cm.class_id = a.class_id AND cm.user_id = ?
WHERE a.deadline IS NOT NULL AND a.deadline >= date('now', '-7 days')
ORDER BY a.deadline ASC LIMIT 5
`),
recentSubmissions: db.prepare(`
SELECT s.id, s.original_name, s.status, s.grade,
s.submitted_at, a.title AS assignment_title
FROM submissions s
LEFT JOIN assignments a ON a.id = s.assignment_id
WHERE s.student_id = ?
ORDER BY s.submitted_at DESC LIMIT 10
`),
notifications: db.prepare(`
SELECT id, type, message, is_read, created_at
FROM parent_notifications WHERE parent_link_id = ?
ORDER BY created_at DESC LIMIT 50
`),
markNotifRead: db.prepare('UPDATE parent_notifications SET is_read = 1 WHERE id = ? AND parent_link_id = ?'),
unreadCount: db.prepare('SELECT COUNT(*) AS cnt FROM parent_notifications WHERE parent_link_id = ? AND is_read = 0'),
history: db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ? AND ts.id < ?
ORDER BY ts.id DESC LIMIT ?
`),
historyFirst: db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ?
ORDER BY ts.id DESC LIMIT ?
`),
};
/* ══════════════════════════════════════════════════════════════════════
STUDENT ENDPOINTS (regular authMiddleware)
══════════════════════════════════════════════════════════════════════ */
/* ── GET /api/parent/my-links ──────────────────────────────────────── */
function getMyLinks(req, res) {
res.json(stmts.linksByStudent.all(req.user.id));
}
/* ── POST /api/parent/links ────────────────────────────────────────── */
function createLink(req, res) {
const { cnt } = stmts.linkCount.get(req.user.id);
if (cnt >= 3) return res.status(400).json({ error: 'Maximum 3 parent links allowed' });
const label = (req.body.label || '').trim().slice(0, 50) || 'Родитель';
const token = crypto.randomBytes(24).toString('hex');
const r = stmts.insertLink.run(req.user.id, token, label);
res.status(201).json({
id: r.lastInsertRowid,
token,
label,
url: `${req.protocol}://${req.get('host')}/parent?t=${token}`,
});
}
/* ── PATCH /api/parent/links/:id ───────────────────────────────────── */
function updateLink(req, res) {
const link = stmts.linkById.get(Number(req.params.id));
if (!link || link.student_id !== req.user.id)
return res.status(404).json({ error: 'Link not found' });
const label = req.body.label !== undefined ? String(req.body.label).trim().slice(0, 50) : link.label;
const is_active = req.body.is_active !== undefined ? (req.body.is_active ? 1 : 0) : link.is_active;
stmts.updateLink.run(label, is_active, link.id);
res.json({ ok: true });
}
/* ── DELETE /api/parent/links/:id ──────────────────────────────────── */
function deleteLink(req, res) {
const link = stmts.linkById.get(Number(req.params.id));
if (!link || link.student_id !== req.user.id)
return res.status(404).json({ error: 'Link not found' });
stmts.deleteLink.run(link.id);
res.json({ ok: true });
}
/* ══════════════════════════════════════════════════════════════════════
PARENT ENDPOINTS (parentAuth middleware)
══════════════════════════════════════════════════════════════════════ */
/* ── POST /api/parent/auth — exchange link token for parent JWT ────── */
function exchangeToken(req, res) {
const { token } = req.body;
if (!token || typeof token !== 'string')
return res.status(400).json({ error: 'token required' });
const link = stmts.linkByToken.get(token);
if (!link || !link.is_active)
return res.status(404).json({ error: 'Link not found or deactivated' });
if (link.expires_at && new Date(link.expires_at) < new Date())
return res.status(410).json({ error: 'Link expired' });
// Update last_used (only here, not on every request)
try { stmts.updateLastUsed.run(link.id); } catch {}
const parentJwt = jwt.sign(
{ type: 'parent', linkId: link.id, studentId: link.student_id },
JWT_SECRET,
{ algorithm: 'HS256', expiresIn: '24h' }
);
// Inline student fetch (avoid importing studentBasic for this one-off)
const student = db.prepare('SELECT name, level, streak_current FROM users WHERE id = ?').get(link.student_id);
res.json({
jwt: parentJwt,
student: {
name: student?.name,
level: student?.level,
streak_current: student?.streak_current,
},
});
}
/* ── GET /api/parent/dashboard — aggregated overview ─────────────────
Optimized: 1 mega CTE + 3 focused queries = 4 total (was 9)
──────────────────────────────────────────────────────────────────── */
function getDashboard(req, res) {
const uid = req.parent.studentId;
// 1. Mega query: student + stats + heatmap + weekly + totals + recent activity
const row = stmts.dashboardMega.get({ uid });
const student = JSON.parse(row.student);
if (!student) return res.status(404).json({ error: 'Student not found' });
const weekly = JSON.parse(row.weekly);
const heatmap = JSON.parse(row.heatmap);
const bySubject = JSON.parse(row.bySubject);
const trend = JSON.parse(row.trend); // Already ASC from SQL
const totals = JSON.parse(row.totals);
const weekAct = JSON.parse(row.weekActivity);
// 2. Weak topics (separate — heavy JOIN, can't merge into CTE)
const weakTopics = stmts.weakTopics.all(uid);
// 3. Deadlines + submissions (lightweight)
const deadlines = stmts.upcomingDeadlines.all(uid, uid);
const submissions = stmts.recentSubmissions.all(uid);
// 4. Course progress + unread notifs
const courseProgress = stmts.courseProgress.all({ uid });
const { cnt: unreadNotifs } = stmts.unreadCount.get(req.parent.linkId);
// Alerts (computed in JS, zero DB cost)
const alerts = [];
if (row.lastSessionDate) {
const daysSince = Math.floor((Date.now() - new Date(row.lastSessionDate).getTime()) / 86400000);
if (daysSince >= 3) {
alerts.push({ type: 'low_activity', icon: 'alert-triangle',
message: `${student.name} не занимался ${daysSince} дней` });
}
}
for (const d of deadlines) {
if (d.done === 0 && new Date(d.deadline) < new Date()) {
alerts.push({ type: 'deadline_missed', icon: 'clock',
message: `Пропущен дедлайн: ${d.title}` });
}
}
res.json({
student,
totals: {
sessions: totals.sessions || 0,
correct: totals.correct || 0,
questions: totals.questions || 0,
avgPct: Math.round(totals.avg_pct || 0),
},
recentActivity: {
lastSessionDate: row.lastSessionDate || null,
sessionsThisWeek: weekAct?.cnt || 0,
avgPctThisWeek: Math.round(weekAct?.avg_pct || 0),
},
weeklyStats: weekly.map(r => ({ week: r.week, sessions: r.sessions, avgPct: Math.round(r.avg_pct || 0) })),
heatmap: heatmap.map(r => ({ day: r.day, count: r.count })),
bySubject: bySubject.map(r => ({
slug: r.slug, name: r.name, sessions: r.sessions,
avgPct: Math.round(r.avg_pct || 0),
})),
trend: trend.map(r => ({
pct: r.total > 0 ? Math.round(r.score * 100 / r.total) : 0,
date: r.finished_at, subject: r.subject_slug,
})),
weakTopics: weakTopics.map(r => ({
topic: r.topic, subject: r.subject_name, errorPct: r.error_pct,
})),
deadlines: deadlines.map(d => ({
id: d.id, title: d.title, deadline: d.deadline,
subject: d.subject_slug, done: d.done > 0,
})),
submissions: submissions.map(s => ({
id: s.id, name: s.original_name, status: s.status,
grade: s.grade, date: s.submitted_at, assignment: s.assignment_title,
})),
courseProgress: courseProgress.map(r => ({
id: r.id, title: r.title, emoji: r.cover_emoji,
done: r.done_lessons, total: r.total_lessons,
pct: r.total_lessons > 0 ? Math.round(r.done_lessons * 100 / r.total_lessons) : 0,
})),
alerts,
unreadNotifs,
});
}
/* ── GET /api/parent/history ─────────────────────────────────────────── */
function getHistory(req, res) {
const uid = req.parent.studentId;
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20));
const cursor = Number(req.query.cursor) || 0;
const rows = cursor
? stmts.history.all(uid, cursor, limit)
: stmts.historyFirst.all(uid, limit);
const nextCursor = rows.length === limit ? rows[rows.length - 1].id : null;
res.json({ rows, nextCursor });
}
/* ── GET /api/parent/notifications ───────────────────────────────────── */
function getNotifications(req, res) {
res.json(stmts.notifications.all(req.parent.linkId));
}
/* ── PATCH /api/parent/notifications/:id/read ────────────────────────── */
function markRead(req, res) {
stmts.markNotifRead.run(Number(req.params.id), req.parent.linkId);
res.json({ ok: true });
}
module.exports = {
getMyLinks, createLink, updateLink, deleteLink,
exchangeToken, getDashboard, getHistory, getNotifications, markRead,
};
@@ -0,0 +1,301 @@
const db = require('../db/db');
/* ── All known permissions ─────────────────────────────────────────────── */
const ALL_PERMISSIONS = [
/* ── Teacher ── */
{
key: 'questions.manage',
role: 'teacher',
label: 'Управление вопросами',
desc: 'Создавать, редактировать и копировать вопросы в банке',
default: 0,
},
{
key: 'questions.delete',
role: 'teacher',
label: 'Удалять вопросы',
desc: 'Удалять вопросы из банка (требует "Управление вопросами")',
default: 0,
},
{
key: 'students.invite',
role: 'teacher',
label: 'Регистрировать учеников',
desc: 'Создавать новые аккаунты учеников напрямую из панели',
default: 0,
},
{
key: 'sessions.reset',
role: 'teacher',
label: 'Сброс попыток',
desc: 'Сбрасывать прохождение теста ученика в своём классе',
default: 1,
},
{
key: 'results.export',
role: 'teacher',
label: 'Экспорт результатов',
desc: 'Выгружать результаты и оценки класса в CSV',
default: 1,
},
{
key: 'classes.manage',
role: 'teacher',
label: 'Управление классами',
desc: 'Создавать, редактировать и удалять свои классы',
default: 1,
},
{
key: 'library.upload',
role: 'teacher',
label: 'Загрузка файлов',
desc: 'Загружать файлы в библиотеку',
default: 1,
},
{
key: 'library.folders',
role: 'teacher',
label: 'Управление папками',
desc: 'Создавать папки и настраивать доступ к ним',
default: 1,
},
{
key: 'schedule.manage',
role: 'teacher',
label: 'Дедлайны заданий',
desc: 'Устанавливать дедлайны и временные окна для заданий',
default: 1,
},
{
key: 'announcements.send',
role: 'teacher',
label: 'Объявления',
desc: 'Публиковать объявления в своих классах',
default: 1,
},
{
key: 'templates.manage',
role: 'teacher',
label: 'Управление шаблонами',
desc: 'Создавать и использовать шаблоны курсов и уроков',
default: 1,
},
{
key: 'templates.public',
role: 'teacher',
label: 'Публикация шаблонов',
desc: 'Делать свои шаблоны публичными для всех учителей',
default: 0,
},
{
key: 'courses.manage',
role: 'teacher',
label: 'Управление курсами',
desc: 'Создавать и редактировать теоретические курсы и уроки',
default: 1,
},
{
key: 'courses.interactive',
role: 'teacher',
label: 'Интерактивные блоки',
desc: 'Добавлять интерактивные задания в уроки (сопоставление, пропуски, порядок)',
default: 1,
},
{
key: 'shop.manage',
role: 'teacher',
label: 'Управление магазином',
desc: 'Создавать и редактировать товары в магазине наград',
default: 0,
},
{
key: 'gamification.manage',
role: 'teacher',
label: 'Управление геймификацией',
desc: 'Начислять XP/монеты ученикам, управлять достижениями',
default: 0,
},
/* ── Student ── */
{
key: 'tests.free',
role: 'student',
label: 'Свободные тесты',
desc: 'Проходить тесты без задания (по предмету / случайно)',
default: 1,
},
{
key: 'board.post',
role: 'student',
label: 'Реакции на доске',
desc: 'Ставить реакции на задания на доске',
default: 1,
},
{
key: 'profile.edit',
role: 'student',
label: 'Редактирование профиля',
desc: 'Изменять своё имя и пароль',
default: 1,
},
{
key: 'shop.purchase',
role: 'student',
label: 'Покупки в магазине',
desc: 'Покупать предметы в магазине наград за монеты',
default: 1,
},
{
key: 'gamification.challenges',
role: 'student',
label: 'Испытания недели',
desc: 'Участвовать в еженедельных испытаниях и получать награды',
default: 1,
},
{
key: 'theory.access',
role: 'student',
label: 'Доступ к теории',
desc: 'Просматривать теоретические курсы и уроки',
default: 1,
},
{
key: 'simulations.access',
role: 'student',
label: 'Доступ к симуляциям',
desc: 'Открывать лабораторию с физическими, химическими и биологическими симуляциями',
default: 1,
},
{
key: 'simulations.quiz',
role: 'student',
label: 'Задания в симуляциях',
desc: 'Использовать режим "Задания" в симуляциях (квиз-режим)',
default: 1,
},
];
/* ── Seed defaults once per startup ───────────────────────────────────── */
function seedDefaults() {
const upsert = db.prepare(
'INSERT OR IGNORE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)'
);
const run = db.transaction(() => {
for (const p of ALL_PERMISSIONS) upsert.run(p.role, p.key, p.default);
});
run();
}
/* ── GET /api/permissions ─────────────────────────────────────────────── */
function getPermissions(_req, res) {
seedDefaults();
const rows = db.prepare('SELECT role, permission, enabled FROM role_permissions').all();
const map = { teacher: {}, student: {} };
for (const r of rows) {
if (map[r.role]) map[r.role][r.permission] = r.enabled === 1;
}
res.json({ permissions: map, definitions: ALL_PERMISSIONS });
}
/* ── POST /api/permissions { role, permission, enabled } ─────────────── */
function setPermission(req, res) {
const { role, permission, enabled } = req.body;
if (!['teacher', 'student'].includes(role))
return res.status(400).json({ error: 'Invalid role' });
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === role))
return res.status(400).json({ error: 'Unknown permission' });
db.prepare(
'INSERT OR REPLACE INTO role_permissions (role, permission, enabled) VALUES (?, ?, ?)'
).run(role, permission, enabled ? 1 : 0);
res.json({ ok: true });
}
/* ── GET /api/permissions/me (any authenticated user) ───────────────── */
function getMyPermissions(req, res) {
const uid = req.user.id;
const role = req.user.role;
if (role === 'admin') return res.json({ role, permissions: [] }); // admins bypass all
seedDefaults();
const roleRows = db.prepare(
'SELECT permission, enabled FROM role_permissions WHERE role = ?'
).all(role);
const roleMap = {};
for (const r of roleRows) roleMap[r.permission] = r.enabled === 1;
const userRows = db.prepare(
'SELECT permission, enabled FROM user_permissions WHERE user_id = ?'
).all(uid);
const userMap = {};
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
const defs = ALL_PERMISSIONS.filter(p => p.role === role);
const result = defs.map(d => ({
key: d.key,
effective: userMap[d.key] !== undefined ? userMap[d.key] : (roleMap[d.key] ?? !!d.default),
}));
res.json({ role, permissions: result });
}
/* ── GET /api/permissions/users/:id ──────────────────────────────────── */
function getUserPermissions(req, res) {
const uid = Number(req.params.id);
const target = require('../db/db').prepare('SELECT id, role FROM users WHERE id = ?').get(uid);
if (!target) return res.status(404).json({ error: 'User not found' });
seedDefaults();
// role-level values
const roleRows = require('../db/db').prepare(
'SELECT permission, enabled FROM role_permissions WHERE role = ?'
).all(target.role);
const roleMap = {};
for (const r of roleRows) roleMap[r.permission] = r.enabled === 1;
// user-level overrides
const userRows = require('../db/db').prepare(
'SELECT permission, enabled FROM user_permissions WHERE user_id = ?'
).all(uid);
const userMap = {};
for (const r of userRows) userMap[r.permission] = r.enabled === 1;
const defs = ALL_PERMISSIONS.filter(p => p.role === target.role);
const result = defs.map(d => ({
key: d.key,
label: d.label,
desc: d.desc,
roleVal: roleMap[d.key] ?? d.default, // effective role-level value
userVal: userMap[d.key], // undefined = no override
effective: userMap[d.key] !== undefined ? userMap[d.key] : (roleMap[d.key] ?? !!d.default),
}));
res.json({ role: target.role, permissions: result });
}
/* ── POST /api/permissions/users/:id { permission, enabled } ─────────── */
function setUserPermission(req, res) {
const uid = Number(req.params.id);
const { permission, enabled } = req.body;
const target = require('../db/db').prepare('SELECT role FROM users WHERE id = ?').get(uid);
if (!target) return res.status(404).json({ error: 'User not found' });
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === target.role))
return res.status(400).json({ error: 'Unknown permission for this role' });
require('../db/db').prepare(
'INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled) VALUES (?, ?, ?)'
).run(uid, permission, enabled ? 1 : 0);
res.json({ ok: true });
}
/* ── DELETE /api/permissions/users/:id/reset (single or all) ─────────── */
function resetUserPermissions(req, res) {
const uid = Number(req.params.id);
const { permission } = req.body; // optional: reset one key
if (permission) {
require('../db/db').prepare(
'DELETE FROM user_permissions WHERE user_id = ? AND permission = ?'
).run(uid, permission);
} else {
require('../db/db').prepare('DELETE FROM user_permissions WHERE user_id = ?').run(uid);
}
res.json({ ok: true });
}
module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions };
+305
View File
@@ -0,0 +1,305 @@
'use strict';
const db = require('../db/db');
const { awardCoins } = require('./gamificationController');
// Incremental migrations
try { db.exec("ALTER TABLE users ADD COLUMN pet_name TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_color TEXT DEFAULT 'purple'"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_last_petted TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_petting_streak INT DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_last_star TEXT"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_bg TEXT DEFAULT 'default'"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_bg_owned TEXT DEFAULT '[]'"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN pet_last_fed TEXT"); } catch {}
const BG_SHOP = [
{ id:'space', name:'Космос', price:50, desc:'Звёздный простор' },
{ id:'forest', name:'Лес', price:50, desc:'Таинственный лес' },
{ id:'aqua', name:'Океан', price:75, desc:'Морская глубина' },
{ id:'sunset', name:'Закат', price:75, desc:'Пурпурный закат' },
];
function _parseOwned(raw) {
try { return JSON.parse(raw || '[]'); } catch { return []; }
}
function _petLevel(xp) {
if (xp >= 80000) return 8;
if (xp >= 40000) return 7;
if (xp >= 20000) return 6;
if (xp >= 10000) return 5;
if (xp >= 5000) return 4;
if (xp >= 2000) return 3;
if (xp >= 500) return 2;
return 1;
}
function _mood(streak, daysSinceLogin) {
if (daysSinceLogin >= 14) return 'sleeping';
if (daysSinceLogin >= 7) return 'hungry';
if (daysSinceLogin >= 3) return 'sad';
if (streak >= 7) return 'ecstatic';
if (streak >= 3) return 'happy';
if (streak >= 1) return 'neutral';
return 'sad';
}
function _accessories(user) {
const acc = [];
if (user.streak_best >= 7) acc.push('hat');
if (user.level >= 5) acc.push('glasses');
if (user.xp >= 5000) acc.push('crown');
if (user.level >= 10) acc.push('star');
return acc;
}
function _quests(userId, streakCurrent) {
const today = new Date().toISOString().slice(0, 10);
const xpToday = db.prepare(
"SELECT COALESCE(SUM(amount),0) AS total FROM xp_log WHERE user_id=? AND date(created_at)=date(?)"
).get(userId, today)?.total || 0;
const testsToday = db.prepare(
"SELECT COUNT(*) AS cnt FROM test_sessions WHERE user_id=? AND status='completed' AND date(finished_at)=date(?)"
).get(userId, today)?.cnt || 0;
return [
{ id:'xp30', icon:'⭐', label:'Набери 30 XP сегодня', done: xpToday >= 30, progress: Math.min(xpToday, 30), goal: 30 },
{ id:'test1', icon:'📝', label:'Пройди 1 тест', done: testsToday >= 1, progress: Math.min(testsToday, 1), goal: 1 },
{ id:'streak2', icon:'🔥', label:'Серия 2+ дней', done: streakCurrent >= 2 },
];
}
function _moodForecast(daysSince) {
if (daysSince >= 7) return null; // already hungry/sleeping
if (daysSince >= 3) return { mood: 'hungry', inDays: Math.max(0, 7 - daysSince) };
const toSad = Math.max(0, 3 - daysSince);
if (toSad <= 2) return { mood: 'sad', inDays: toSad };
return null;
}
/* ── GET /api/pet ─────────────────────────────────────────────────────── */
function getPet(req, res) {
const user = db.prepare(
`SELECT xp, level, streak_current, streak_best, streak_date, coins,
pet_name, last_login, pet_color, pet_last_petted, pet_petting_streak,
pet_bg, pet_bg_owned, pet_last_fed
FROM users WHERE id = ?`
).get(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
const now = new Date();
const lastXpRow = db.prepare("SELECT MAX(created_at) AS t FROM xp_log WHERE user_id = ?").get(req.user.id);
const lastSessRow = db.prepare("SELECT MAX(finished_at) AS t FROM test_sessions WHERE user_id = ? AND status = 'completed'").get(req.user.id);
const candidates = [
user.last_login ? new Date(user.last_login) : null,
lastXpRow?.t ? new Date(lastXpRow.t) : null,
lastSessRow?.t ? new Date(lastSessRow.t) : null,
].filter(Boolean);
const lastActive = candidates.length ? new Date(Math.max(...candidates.map(d => d.getTime()))) : now;
const daysSince = Math.max(0, Math.floor((now - lastActive) / 86400000));
const since7 = new Date(now - 7 * 86400000).toISOString();
const recentXP = db.prepare(
'SELECT COALESCE(SUM(amount),0) AS total FROM xp_log WHERE user_id = ? AND created_at >= ?'
).get(req.user.id, since7)?.total || 0;
const petLvl = _petLevel(user.xp);
const mood = _mood(user.streak_current, daysSince);
const accessories = _accessories(user);
const thresholds = [0, 500, 2000, 5000, 10000, 20000, 40000, 80000, Infinity];
const xpForNext = thresholds[petLvl] ?? Infinity;
const xpForCurr = thresholds[petLvl - 1] ?? 0;
const d0 = new Date(now); d0.setDate(d0.getDate() - 6);
const weekStart = d0.toISOString().slice(0, 10);
const weekRows = db.prepare(
"SELECT date(created_at) AS d, COALESCE(SUM(amount),0) AS xp FROM xp_log WHERE user_id = ? AND date(created_at) >= date(?) GROUP BY date(created_at)"
).all(req.user.id, weekStart);
const weekMap = new Map(weekRows.map(r => [r.d, r.xp]));
const weeklyXP = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(now); d.setDate(d.getDate() - i);
const dateStr = d.toISOString().slice(0, 10);
weeklyXP.push({ day: d.toLocaleDateString('ru', { weekday: 'short' }), xp: weekMap.get(dateStr) || 0 });
}
const SOURCE_LABELS = {
hangman_win: 'Виселица', crossword_win: 'Кроссворд',
test_answer: 'Тест', challenge: 'Задание',
lab_activity: 'Лаборатория', assignment: 'Домашнее задание',
pet_petting: 'Поглаживание', pet_feeding: 'Кормёжка питомца',
mood_ecstatic: 'Бонус настроения', correct_answers: 'Тест',
test_complete: 'Тест', 'test_90+': 'Тест 90%+', test_perfect: 'Тест 100%',
};
const rawActivity = db.prepare(
"SELECT amount, reason, created_at FROM xp_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 6"
).all(req.user.id);
const recentActivity = rawActivity.map(r => ({
xp: r.amount, label: SOURCE_LABELS[r.reason] || r.reason, at: r.created_at,
}));
const pettingCooldown = user.pet_last_petted
? Math.max(0, 60 - Math.floor((now - new Date(user.pet_last_petted)) / 1000))
: 0;
const feedCooldown = user.pet_last_fed
? Math.max(0, 1800 - Math.floor((now - new Date(user.pet_last_fed)) / 1000))
: 0;
res.json({
petName: user.pet_name || 'Квантик',
petLevel: petLvl,
petColor: user.pet_color || 'purple',
mood,
daysSinceLogin: daysSince,
accessories,
xp: user.xp,
level: user.level,
streakCurrent: user.streak_current,
streakBest: user.streak_best,
coins: user.coins,
pettingStreak: user.pet_petting_streak || 0,
recentXP,
weeklyXP,
recentActivity,
xpForCurrLevel: xpForCurr,
xpForNextLevel: xpForNext === Infinity ? null : xpForNext,
quests: _quests(req.user.id, user.streak_current),
moodForecast: _moodForecast(daysSince),
pettingCooldown,
feedCooldown,
petBg: user.pet_bg || 'default',
petBgOwned: _parseOwned(user.pet_bg_owned),
});
}
/* ── POST /api/pet/pet ────────────────────────────────────────────────── */
function petAction(req, res) {
const user = db.prepare('SELECT pet_last_petted, pet_petting_streak FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const now = new Date();
if (user.pet_last_petted) {
const diff = (now - new Date(user.pet_last_petted)) / 1000;
if (diff < 60) return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(60 - diff) });
}
const today = now.toISOString().slice(0, 10);
const yesterday = new Date(now - 86400000).toISOString().slice(0, 10);
let streak = user.pet_petting_streak || 0;
if (user.pet_last_petted) {
const lastDate = user.pet_last_petted.slice(0, 10);
if (lastDate === today) { /* same day, keep */ }
else if (lastDate === yesterday) streak++;
else streak = 1;
} else {
streak = 1;
}
try { awardCoins(req.user.id, 2, 'pet_petting'); } catch {}
db.prepare('UPDATE users SET pet_last_petted=?, pet_petting_streak=? WHERE id=?')
.run(now.toISOString(), streak, req.user.id);
res.json({ ok: true, coins: 2, pettingStreak: streak });
}
/* ── PATCH /api/pet/name ──────────────────────────────────────────────── */
function renamePet(req, res) {
let { name } = req.body;
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
name = name.trim().slice(0, 24);
if (!name) return res.status(400).json({ error: 'name required' });
db.prepare('UPDATE users SET pet_name = ? WHERE id = ?').run(name, req.user.id);
res.json({ ok: true, name });
}
/* ── PATCH /api/pet/color ─────────────────────────────────────────────── */
function updateColor(req, res) {
const { color } = req.body;
const VALID = ['purple', 'cyan', 'gold', 'red', 'green', 'blue'];
if (!VALID.includes(color)) return res.status(400).json({ error: 'invalid color' });
db.prepare('UPDATE users SET pet_color = ? WHERE id = ?').run(color, req.user.id);
res.json({ ok: true, color });
}
/* ── GET /api/pet/shop ────────────────────────────────────────────────── */
function getShop(req, res) {
const user = db.prepare('SELECT coins, pet_bg, pet_bg_owned FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const owned = _parseOwned(user.pet_bg_owned);
res.json({
coins: user.coins || 0,
currentBg: user.pet_bg || 'default',
items: BG_SHOP.map(item => ({ ...item, owned: owned.includes(item.id) })),
});
}
/* ── POST /api/pet/shop/buy ───────────────────────────────────────────── */
function buyBg(req, res) {
const { id } = req.body;
const item = BG_SHOP.find(b => b.id === id);
if (!item) return res.status(400).json({ error: 'invalid item' });
const user = db.prepare('SELECT coins, pet_bg_owned FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const owned = _parseOwned(user.pet_bg_owned);
if (!owned.includes(id)) {
if ((user.coins || 0) < item.price) return res.status(400).json({ error: 'insufficient_coins' });
db.prepare('UPDATE users SET coins=coins-?, pet_bg_owned=?, pet_bg=? WHERE id=?')
.run(item.price, JSON.stringify([...owned, id]), id, req.user.id);
} else {
db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id);
}
const updated = db.prepare('SELECT coins FROM users WHERE id=?').get(req.user.id);
res.json({ ok: true, bg: id, coins: updated.coins });
}
/* ── PATCH /api/pet/bg ────────────────────────────────────────────────── */
function setBg(req, res) {
const { id } = req.body;
if (id !== 'default') {
const owned = _parseOwned(db.prepare('SELECT pet_bg_owned FROM users WHERE id=?').get(req.user.id)?.pet_bg_owned);
if (!owned.includes(id)) return res.status(403).json({ error: 'not owned' });
}
db.prepare('UPDATE users SET pet_bg=? WHERE id=?').run(id, req.user.id);
res.json({ ok: true, bg: id });
}
/* ── POST /api/pet/star ───────────────────────────────────────────────── */
function starCatch(req, res) {
const user = db.prepare('SELECT pet_last_star FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const now = new Date();
if (user.pet_last_star) {
const diff = (now - new Date(user.pet_last_star)) / 1000;
if (diff < 3600) return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(3600 - diff) });
}
try { awardCoins(req.user.id, 5, 'star_catch'); } catch {}
db.prepare('UPDATE users SET pet_last_star=? WHERE id=?').run(now.toISOString(), req.user.id);
res.json({ ok: true, coins: 5 });
}
/* ── POST /api/pet/feed ───────────────────────────────────────────────── */
// Called when mini-game "feed pet" is correctly answered on frontend.
// 30-minute cooldown; awards 15 XP + marks pet as fed.
function feedPet(req, res) {
const user = db.prepare('SELECT pet_last_fed FROM users WHERE id=?').get(req.user.id);
if (!user) return res.status(404).json({ error: 'not found' });
const now = new Date();
const COOLDOWN_SEC = 1800; // 30 minutes
if (user.pet_last_fed) {
const diff = (now - new Date(user.pet_last_fed)) / 1000;
if (diff < COOLDOWN_SEC) {
return res.status(429).json({ error: 'cooldown', remaining: Math.ceil(COOLDOWN_SEC - diff) });
}
}
try {
const { awardXP } = require('./gamificationController');
awardXP(req.user.id, 15, 'pet_feeding');
} catch (e) { console.error('[feedPet] awardXP:', e.message); }
db.prepare('UPDATE users SET pet_last_fed=? WHERE id=?').run(now.toISOString(), req.user.id);
const updated = db.prepare('SELECT xp, coins FROM users WHERE id=?').get(req.user.id);
res.json({ ok: true, xpAwarded: 15, xp: updated.xp, coins: updated.coins });
}
module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, feedPet };
@@ -0,0 +1,248 @@
const db = require('../db/db');
const { stripTags } = require('../utils/sanitize');
/* helper: find or create topic by name within a subject */
function resolveTopicId(subjectId, topicId, topicName) {
if (topicId) return Number(topicId);
if (!topicName?.trim()) return null;
const name = topicName.trim();
const existing = db.prepare('SELECT id FROM topics WHERE subject_id = ? AND LOWER(name) = LOWER(?)').get(subjectId, name);
if (existing) return existing.id;
return db.prepare('INSERT INTO topics (subject_id, name) VALUES (?, ?)').run(subjectId, name).lastInsertRowid;
}
/* ── GET /api/questions?subject=bio&topic_id=1&sort=diff_asc&page=1&limit=50 */
function list(req, res) {
const { subject, topic_id, sort, source_type, q, difficulty, type } = req.query;
const limit = Math.min(500, Math.max(1, Number(req.query.limit) || 100));
const page = Math.max(1, Number(req.query.page) || 1);
const offset = (page - 1) * limit;
const subj = subject
? db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject)
: null;
if (subject && !subj) return res.status(404).json({ error: 'Subject not found' });
const ORDER = {
date_desc: 'q.id DESC',
date_asc: 'q.id ASC',
diff_asc: 'q.difficulty ASC, q.id DESC',
diff_desc: 'q.difficulty DESC, q.id DESC',
};
const orderBy = ORDER[sort] || 'q.id DESC';
let where = 'WHERE 1=1';
const args = [];
if (subj) { where += ' AND q.subject_id = ?'; args.push(subj.id); }
if (topic_id) { where += ' AND q.topic_id = ?'; args.push(topic_id); }
if (source_type) { where += ' AND q.source_type = ?'; args.push(source_type); }
if (difficulty) { where += ' AND q.difficulty = ?'; args.push(Number(difficulty)); }
if (type) { where += ' AND q.type = ?'; args.push(type); }
if (q?.trim()) { where += ' AND q.text LIKE ?'; args.push(`%${q.trim()}%`); }
const { total } = db.prepare(`SELECT COUNT(*) AS total FROM questions q ${where}`).get(...args);
const sql = `
SELECT q.id, q.text, q.type, q.correct_text, q.difficulty, q.explanation, q.image,
q.year, q.source_type,
t.name AS topic, t.id AS topic_id,
s.name AS subject_name, s.slug AS subject_slug,
(SELECT json_group_array(json_object(
'id', o.id, 'text', o.text, 'is_correct', o.is_correct, 'order_index', o.order_index, 'match_pair', o.match_pair
) ORDER BY o.order_index)
FROM options o WHERE o.question_id = q.id) AS options_json
FROM questions q
LEFT JOIN topics t ON t.id = q.topic_id
LEFT JOIN subjects s ON s.id = q.subject_id
${where}
ORDER BY ${orderBy} LIMIT ? OFFSET ?
`;
const rows = db.prepare(sql).all(...args, limit, offset).map(r => ({
...r,
options: JSON.parse(r.options_json || '[]'),
options_json: undefined,
}));
res.json({ rows, total, page, limit });
}
/* ── POST /api/questions ─────────────────────────────────────────────── */
function create(req, res) {
const { subject_slug, topic_id, topic_name, type = 'single', correct_text, difficulty = 1, explanation, options, image } = req.body;
const text = stripTags((req.body.text || '').trim());
if (!subject_slug || !text) return res.status(400).json({ error: 'subject_slug and text are required' });
if (text.length > 2000) return res.status(400).json({ error: 'text too long (max 2000 chars)' });
if (type !== 'short_answer' && !options?.length)
return res.status(400).json({ error: 'options required for this question type' });
if (type !== 'short_answer' && type !== 'matching' && !options.some(o => o.is_correct))
return res.status(400).json({ error: 'At least one option must be correct' });
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subj) return res.status(404).json({ error: 'Subject not found' });
const resolvedTopicId = resolveTopicId(subj.id, topic_id, topic_name);
try {
const qid = db.transaction(() => {
const { lastInsertRowid } = db.prepare(
'INSERT INTO questions (subject_id, topic_id, text, type, correct_text, difficulty, explanation, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(subj.id, resolvedTopicId, text, type, type === 'short_answer' ? (correct_text || null) : null, difficulty, explanation || null, image || null);
const insertOpt = db.prepare(
'INSERT INTO options (question_id, text, is_correct, order_index, match_pair) VALUES (?, ?, ?, ?, ?)'
);
options.forEach((o, i) => insertOpt.run(lastInsertRowid, o.text, type === 'matching' ? 0 : (o.is_correct ? 1 : 0), i, o.match_pair || null));
return lastInsertRowid;
})();
res.status(201).json({ id: qid });
} catch (err) {
console.error('[question create]', err.message);
res.status(500).json({ error: 'Ошибка создания вопроса' });
}
}
/* ── POST /api/questions/:id/duplicate ──────────────────────────────── */
function duplicate(req, res) {
const q = db.prepare(`
SELECT q.*, s.slug AS subject_slug FROM questions q
LEFT JOIN subjects s ON s.id = q.subject_id
WHERE q.id = ?
`).get(req.params.id);
if (!q) return res.status(404).json({ error: 'Question not found' });
const opts = db.prepare('SELECT text, is_correct, order_index FROM options WHERE question_id = ? ORDER BY order_index').all(q.id);
try {
const newId = db.transaction(() => {
const { lastInsertRowid } = db.prepare(
'INSERT INTO questions (subject_id, topic_id, text, difficulty, explanation, type, correct_text, image) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(q.subject_id, q.topic_id, q.text + ' (копия)', q.difficulty, q.explanation, q.type, q.correct_text, q.image);
const ins = db.prepare('INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?, ?, ?, ?)');
opts.forEach(o => ins.run(lastInsertRowid, o.text, o.is_correct, o.order_index));
return lastInsertRowid;
})();
res.status(201).json({ id: newId });
} catch (err) {
console.error('[question duplicate]', err.message);
res.status(500).json({ error: 'Ошибка дублирования вопроса' });
}
}
/* ── PUT /api/questions/:id ──────────────────────────────────────────── */
function update(req, res) {
const { type, correct_text, difficulty, explanation, topic_id, topic_name, options, image } = req.body;
const text = req.body.text !== undefined ? stripTags(String(req.body.text).trim()) : undefined;
const qid = req.params.id;
if (text !== undefined && text.length > 2000)
return res.status(400).json({ error: 'text too long (max 2000 chars)' });
const q = db.prepare('SELECT id, subject_id, type AS oldType FROM questions WHERE id = ?').get(qid);
if (!q) return res.status(404).json({ error: 'Question not found' });
const resolvedTopicId = resolveTopicId(q.subject_id, topic_id, topic_name);
const qtype = type || q.oldType || 'single';
try {
db.transaction(() => {
db.prepare(
'UPDATE questions SET text = ?, type = ?, correct_text = ?, difficulty = ?, explanation = ?, topic_id = ?, image = ? WHERE id = ?'
).run(text, qtype, qtype === 'short_answer' ? (correct_text || null) : null, difficulty, explanation || null, resolvedTopicId, image !== undefined ? (image || null) : db.prepare('SELECT image FROM questions WHERE id = ?').get(qid)?.image, qid);
if (options?.length) {
if (qtype !== 'matching' && !options.some(o => o.is_correct))
throw Object.assign(new Error('At least one option must be correct'), { status: 400 });
db.prepare('DELETE FROM options WHERE question_id = ?').run(qid);
const ins = db.prepare(
'INSERT INTO options (question_id, text, is_correct, order_index, match_pair) VALUES (?, ?, ?, ?, ?)'
);
options.forEach((o, i) => ins.run(qid, o.text, qtype === 'matching' ? 0 : (o.is_correct ? 1 : 0), i, o.match_pair || null));
}
})();
res.json({ id: Number(qid) });
} catch (err) {
res.status(err.status || 500).json({ error: err.message });
}
}
/* ── DELETE /api/questions/:id ───────────────────────────────────────── */
function remove(req, res) {
const q = db.prepare('SELECT id FROM questions WHERE id = ?').get(req.params.id);
if (!q) return res.status(404).json({ error: 'Question not found' });
db.prepare('DELETE FROM questions WHERE id = ?').run(req.params.id);
res.json({ deleted: Number(req.params.id) });
}
/* ── POST /api/questions/import (CSV upload) ────────────────────────── */
/* CSV format (semicolon-separated, first row = header):
subject_slug;topic;text;difficulty;type;opt1;c1;opt2;c2;opt3;c3;opt4;c4;correct_text;explanation;year
- type: single | multi | true_false | short_answer
- c1-c4: 1 = correct, 0 = wrong (ignored for short_answer)
- correct_text: used only for short_answer type
*/
function importCSV(req, res) {
if (!req.file) return res.status(400).json({ error: 'CSV file required' });
const raw = req.file.buffer.toString('utf-8').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = raw.split('\n').map(l => l.trim()).filter(l => l.length);
if (lines.length < 2) return res.status(400).json({ error: 'CSV is empty or has only header' });
// skip header row
const dataLines = lines.slice(1);
const errors = [];
let imported = 0;
const insQ = db.prepare('INSERT INTO questions (subject_id, topic_id, text, type, correct_text, difficulty, explanation, year) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
const insOpt = db.prepare('INSERT INTO options (question_id, text, is_correct, order_index) VALUES (?, ?, ?, ?)');
try {
db.transaction(() => {
for (let i = 0; i < dataLines.length; i++) {
const cols = dataLines[i].split(';');
const [subject_slug, topic, text, diffStr, type = 'single',
opt1 = '', c1 = '0', opt2 = '', c2 = '0',
opt3 = '', c3 = '0', opt4 = '', c4 = '0',
correct_text = '', explanation = '', yearStr = ''] = cols.map(c => c.trim());
if (!subject_slug || !text) { errors.push(`Строка ${i + 2}: пропущен subject_slug или text`); continue; }
const subj = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subj) { errors.push(`Строка ${i + 2}: неизвестный subject_slug «${subject_slug}»`); continue; }
const difficulty = Math.min(3, Math.max(1, Number(diffStr) || 1));
const year = yearStr ? Number(yearStr) || null : null;
const qtype = ['single', 'multi', 'true_false', 'short_answer'].includes(type) ? type : 'single';
const topicId = resolveTopicId(subj.id, null, topic || null);
const isShort = qtype === 'short_answer';
const options = [
{ text: opt1, is_correct: c1 === '1' },
{ text: opt2, is_correct: c2 === '1' },
{ text: opt3, is_correct: c3 === '1' },
{ text: opt4, is_correct: c4 === '1' },
].filter(o => o.text.length > 0);
if (!isShort && options.length < 2) { errors.push(`Строка ${i + 2}: нужно минимум 2 варианта ответа`); continue; }
if (!isShort && !options.some(o => o.is_correct)) { errors.push(`Строка ${i + 2}: нет правильного варианта (укажи 1 в столбце c1–c4)`); continue; }
const { lastInsertRowid: qid } = insQ.run(
subj.id, topicId, text, qtype,
isShort ? (correct_text || null) : null,
difficulty, explanation || null, year
);
if (!isShort) options.forEach((o, idx) => insOpt.run(qid, o.text, o.is_correct ? 1 : 0, idx));
imported++;
}
})();
} catch (e) {
return res.status(500).json({ error: e.message });
}
res.json({ imported, errors });
}
module.exports = { list, create, duplicate, update, remove, importCSV };
@@ -0,0 +1,342 @@
const db = require('../db/db');
const { awardXP, checkRedBookAchievements } = require('./gamificationController');
/* ── helpers ─────────────────────────────────────────────────────────── */
const stmts = {
groups: db.prepare('SELECT * FROM rb_groups ORDER BY name_ru'),
habitats: db.prepare('SELECT * FROM rb_habitats ORDER BY name'),
speciesById: db.prepare(`
SELECT s.*, g.name_ru as group_name, g.icon as group_icon, g.color as group_color,
h.name as habitat_name, h.type as habitat_type
FROM rb_species s
JOIN rb_groups g ON g.id = s.group_id
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
WHERE s.id = ?
`),
regionsBySpecies: db.prepare('SELECT region_code FROM rb_species_regions WHERE species_id = ?'),
popdata: db.prepare('SELECT year, count_estimate, source FROM rb_population_data WHERE species_id = ? ORDER BY year'),
foodWebPrey: db.prepare(`
SELECT fw.strength, s.id, s.name_ru, s.name_lat, s.category,
g.icon as group_icon, g.color as group_color
FROM rb_food_web fw
JOIN rb_species s ON s.id = fw.prey_id
JOIN rb_groups g ON g.id = s.group_id
WHERE fw.predator_id = ?
`),
foodWebPredators: db.prepare(`
SELECT fw.strength, s.id, s.name_ru, s.name_lat, s.category,
g.icon as group_icon, g.color as group_color
FROM rb_food_web fw
JOIN rb_species s ON s.id = fw.predator_id
JOIN rb_groups g ON g.id = s.group_id
WHERE fw.prey_id = ?
`),
collection: db.prepare('SELECT species_id, unlock_method, notes, unlocked_at FROM rb_user_collection WHERE user_id = ?'),
hasCollect: db.prepare('SELECT 1 FROM rb_user_collection WHERE user_id = ? AND species_id = ?'),
addCollect: db.prepare('INSERT OR IGNORE INTO rb_user_collection (user_id, species_id, unlock_method) VALUES (?,?,?)'),
quests: db.prepare('SELECT * FROM rb_quests ORDER BY id'),
userQuests: db.prepare('SELECT * FROM rb_user_quests WHERE user_id = ?'),
startQuest: db.prepare('INSERT OR IGNORE INTO rb_user_quests (user_id, quest_id) VALUES (?,?)'),
questById: db.prepare('SELECT * FROM rb_quests WHERE id = ?'),
userQuestRow:db.prepare('SELECT * FROM rb_user_quests WHERE user_id = ? AND quest_id = ?'),
completeQ: db.prepare("UPDATE rb_user_quests SET status='completed', completed_at=datetime('now') WHERE user_id=? AND quest_id=?"),
sightings: db.prepare(`
SELECT rs.*, u.name as user_name, s.name_ru as species_name
FROM rb_sightings rs
JOIN users u ON u.id = rs.user_id
JOIN rb_species s ON s.id = rs.species_id
ORDER BY rs.created_at DESC LIMIT 50
`),
addSighting: db.prepare('INSERT INTO rb_sightings (user_id, species_id, region_code, description) VALUES (?,?,?,?)'),
mapData: db.prepare(`
SELECT r.region_code, COUNT(r.species_id) as total,
SUM(CASE WHEN s.category='CR' THEN 1 ELSE 0 END) as cr,
SUM(CASE WHEN s.category='EN' THEN 1 ELSE 0 END) as en,
SUM(CASE WHEN s.category='VU' THEN 1 ELSE 0 END) as vu
FROM rb_species_regions r
JOIN rb_species s ON s.id = r.species_id
GROUP BY r.region_code
`),
};
function buildSpeciesList(filter = {}) {
let sql = `
SELECT s.id, s.name_ru, s.name_be, s.name_lat, s.category, s.by_category,
s.photo_url, s.model_type, s.biomass_kg, s.interesting_fact,
g.name_ru as group_name, g.icon as group_icon, g.color as group_color,
h.name as habitat_name, h.type as habitat_type
FROM rb_species s
JOIN rb_groups g ON g.id = s.group_id
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
`;
const cond = [], params = [];
if (filter.group_id) { cond.push('s.group_id = ?'); params.push(filter.group_id); }
if (filter.category) { cond.push('s.category = ?'); params.push(filter.category); }
if (filter.habitat_id) { cond.push('s.habitat_id = ?'); params.push(filter.habitat_id); }
if (filter.region) {
sql += ' JOIN rb_species_regions rr ON rr.species_id = s.id';
cond.push('rr.region_code = ?'); params.push(filter.region);
}
if (filter.q) {
cond.push('(s.name_ru LIKE ? OR s.name_lat LIKE ? OR s.name_be LIKE ?)');
const like = `%${filter.q}%`;
params.push(like, like, like);
}
if (cond.length) sql += ' WHERE ' + cond.join(' AND ');
sql += ' ORDER BY s.category, s.name_ru';
const limit = Math.min(parseInt(filter.limit) || 50, 100);
const offset = parseInt(filter.offset) || 0;
sql += ` LIMIT ${limit} OFFSET ${offset}`;
return db.prepare(sql).all(...params);
}
/* ── GET /api/red-book/groups ───────────────────────────────────────── */
exports.getGroups = (req, res) => {
const groups = stmts.groups.all();
// attach count
const counts = db.prepare(`
SELECT group_id, COUNT(*) as n,
SUM(CASE WHEN category='CR' THEN 1 ELSE 0 END) as cr,
SUM(CASE WHEN category='EN' THEN 1 ELSE 0 END) as en
FROM rb_species GROUP BY group_id
`).all();
const countMap = {};
counts.forEach(c => countMap[c.group_id] = { n: c.n, cr: c.cr, en: c.en });
groups.forEach(g => Object.assign(g, countMap[g.id] || { n: 0, cr: 0, en: 0 }));
res.json(groups);
};
/* ── GET /api/red-book/habitats ─────────────────────────────────────── */
exports.getHabitats = (req, res) => {
res.json(stmts.habitats.all());
};
/* ── GET /api/red-book/species ──────────────────────────────────────── */
exports.getSpecies = (req, res) => {
const list = buildSpeciesList(req.query);
// total for pagination
const total = db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n;
// user collection ids
let collected = new Set();
if (req.user) {
stmts.collection.all(req.user.id).forEach(r => collected.add(r.species_id));
}
list.forEach(s => {
try { s.threats = JSON.parse(s.threats || '[]'); } catch { s.threats = []; }
s.collected = collected.has(s.id);
});
res.json({ total, species: list });
};
/* ── GET /api/red-book/species/:id ─────────────────────────────────── */
exports.getSpeciesById = (req, res) => {
const s = stmts.speciesById.get(req.params.id);
if (!s) return res.status(404).json({ error: 'Вид не найден' });
s.regions = stmts.regionsBySpecies.all(s.id).map(r => r.region_code);
s.population_data = stmts.popdata.all(s.id);
s.prey = stmts.foodWebPrey.all(s.id);
s.predators = stmts.foodWebPredators.all(s.id);
try { s.threats = JSON.parse(s.threats || '[]'); } catch { s.threats = []; }
try { s.population_trend = JSON.parse(s.population_trend || '[]'); } catch { s.population_trend = []; }
if (req.user) {
s.collected = !!stmts.hasCollect.get(req.user.id, s.id);
}
res.json(s);
};
/* ── GET /api/red-book/map-data ─────────────────────────────────────── */
exports.getMapData = (req, res) => {
res.json(stmts.mapData.all());
};
/* ── GET /api/red-book/food-web ─────────────────────────────────────── */
exports.getFoodWeb = (req, res) => {
const ids = (req.query.species_ids || '').split(',').map(Number).filter(Boolean);
if (!ids.length) {
// return full web
const nodes = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.biomass_kg, s.description,
g.icon, g.color, g.id as group_id,
h.id as habitat_id, h.type as habitat_type, h.name as habitat_name
FROM rb_species s
JOIN rb_groups g ON g.id = s.group_id
LEFT JOIN rb_habitats h ON h.id = s.habitat_id
`).all();
const links = db.prepare('SELECT predator_id as source, prey_id as target, strength FROM rb_food_web').all();
return res.json({ nodes, links });
}
const ph = ids.map(() => '?').join(',');
const nodes = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.biomass_kg, s.description, g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
WHERE s.id IN (${ph})
`).all(...ids);
const links = db.prepare(`
SELECT predator_id as source, prey_id as target, strength FROM rb_food_web
WHERE predator_id IN (${ph}) OR prey_id IN (${ph})
`).all(...ids, ...ids);
res.json({ nodes, links });
};
/* ── GET /api/red-book/biome/:habitatId ─────────────────────────────── */
exports.getBiomeSpecies = (req, res) => {
const list = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.photo_url, s.model_type,
s.biomass_kg, g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
WHERE s.habitat_id = ?
ORDER BY s.category, s.name_ru
`).all(req.params.habitatId);
res.json(list);
};
/* ── POST /api/red-book/species/:id/collect ─────────────────────────── */
exports.collectSpecies = (req, res) => {
const userId = req.user.id;
const speciesId = parseInt(req.params.id);
const sp = stmts.speciesById.get(speciesId);
if (!sp) return res.status(404).json({ error: 'Вид не найден' });
const already = stmts.hasCollect.get(userId, speciesId);
if (already) return res.json({ already: true, message: 'Вид уже в коллекции' });
stmts.addCollect.run(userId, speciesId, req.body?.method || 'explore');
// Award XP (via gamification service — also updates level in DB)
const xpMap = { CR: 50, EN: 40, VU: 30, NT: 20, LC: 10 };
const xp = xpMap[sp.category] || 20;
try { awardXP(userId, xp, `Открыт вид: ${sp.name_ru}`); } catch {}
// Auto-complete any active quests that required this species
const completedQuests = [];
try {
const activeQuests = db.prepare(`
SELECT q.*, uq.quest_id FROM rb_user_quests uq
JOIN rb_quests q ON q.id = uq.quest_id
WHERE uq.user_id = ? AND uq.status = 'active'
`).all(userId);
// Get all collected species ids for this user
const collectedIds = new Set(
db.prepare('SELECT species_id FROM rb_user_collection WHERE user_id = ?').all(userId).map(r => r.species_id)
);
for (const quest of activeQuests) {
const requiredIds = JSON.parse(quest.species_ids || '[]');
if (requiredIds.every(id => collectedIds.has(id))) {
stmts.completeQ.run(userId, quest.id);
// Award quest XP via gamification service
try { awardXP(userId, quest.xp_reward, `Квест выполнен: ${quest.title}`); } catch {}
completedQuests.push({ id: quest.id, title: quest.title, xp_reward: quest.xp_reward });
}
}
} catch {}
// Check Red Book achievements (non-blocking)
try { checkRedBookAchievements(userId); } catch {}
res.json({ collected: true, xp_earned: xp, species: { id: sp.id, name_ru: sp.name_ru, category: sp.category }, completed_quests: completedQuests });
};
/* ── GET /api/red-book/collection ───────────────────────────────────── */
exports.getCollection = (req, res) => {
const rows = stmts.collection.all(req.user.id);
const ids = rows.map(r => r.species_id);
if (!ids.length) return res.json({ total: 0, species: [] });
const ph = ids.map(() => '?').join(',');
const species = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.photo_url,
g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
WHERE s.id IN (${ph})
`).all(...ids);
const meta = {};
rows.forEach(r => meta[r.species_id] = { method: r.unlock_method, notes: r.notes, at: r.unlocked_at });
species.forEach(s => Object.assign(s, meta[s.id] || {}));
res.json({ total: species.length, species });
};
/* ── GET /api/red-book/quests ───────────────────────────────────────── */
exports.getQuests = (req, res) => {
const quests = stmts.quests.all();
quests.forEach(q => q.species_ids = JSON.parse(q.species_ids || '[]'));
if (!req.user) return res.json(quests);
const userQ = {};
stmts.userQuests.all(req.user.id).forEach(uq => userQ[uq.quest_id] = uq);
quests.forEach(q => {
const uq = userQ[q.id];
q.user_status = uq?.status || 'locked';
q.user_progress = uq ? JSON.parse(uq.progress || '{}') : {};
});
res.json(quests);
};
/* ── POST /api/red-book/quests/:id/start ────────────────────────────── */
exports.startQuest = (req, res) => {
const q = stmts.questById.get(req.params.id);
if (!q) return res.status(404).json({ error: 'Квест не найден' });
stmts.startQuest.run(req.user.id, q.id);
res.json({ started: true });
};
/* ── GET /api/red-book/sightings ────────────────────────────────────── */
exports.getSightings = (req, res) => {
const speciesId = req.query.species_id ? parseInt(req.query.species_id) : null;
let sql = `
SELECT rs.id, rs.species_id, rs.region_code, rs.description,
rs.confirmed_by_teacher, rs.created_at,
u.name as user_name,
s.name_ru as species_name,
g.icon as species_icon
FROM rb_sightings rs
JOIN users u ON u.id = rs.user_id
JOIN rb_species s ON s.id = rs.species_id
JOIN rb_groups g ON g.id = s.group_id
`;
const params = [];
if (speciesId) { sql += ' WHERE rs.species_id = ?'; params.push(speciesId); }
sql += ' ORDER BY rs.created_at DESC LIMIT 50';
res.json(db.prepare(sql).all(...params));
};
/* ── POST /api/red-book/sightings ───────────────────────────────────── */
exports.addSighting = (req, res) => {
const { species_id, region_code, description } = req.body || {};
if (!species_id) return res.status(400).json({ error: 'species_id обязателен' });
const id = stmts.addSighting.run(req.user.id, species_id, region_code || '', description || '').lastInsertRowid;
try { checkRedBookAchievements(req.user.id); } catch {}
res.json({ id });
};
/* ── GET /api/red-book/stats ────────────────────────────────────────── */
exports.getStats = (req, res) => {
const totals = db.prepare(`
SELECT COUNT(*) as total,
SUM(CASE WHEN category='CR' THEN 1 ELSE 0 END) as cr,
SUM(CASE WHEN category='EN' THEN 1 ELSE 0 END) as en,
SUM(CASE WHEN category='VU' THEN 1 ELSE 0 END) as vu,
SUM(CASE WHEN category='NT' THEN 1 ELSE 0 END) as nt,
SUM(CASE WHEN category='LC' THEN 1 ELSE 0 END) as lc
FROM rb_species
`).get();
let collected = 0;
if (req.user) {
collected = db.prepare('SELECT COUNT(*) as n FROM rb_user_collection WHERE user_id = ?').get(req.user.id).n;
}
res.json({ ...totals, collected });
};
/* ── GET /api/red-book/daily ────────────────────────────────────────── */
exports.getDaily = (req, res) => {
// deterministic by day of year
const dayOfYear = Math.floor((Date.now() - new Date(new Date().getFullYear(), 0, 0)) / 86400000);
const total = db.prepare('SELECT COUNT(*) as n FROM rb_species').get().n;
const offset = dayOfYear % total;
const daily = db.prepare(`
SELECT s.id, s.name_ru, s.name_lat, s.category, s.interesting_fact,
s.description, s.photo_url, g.icon, g.color
FROM rb_species s JOIN rb_groups g ON g.id = s.group_id
ORDER BY s.id LIMIT 1 OFFSET ?
`).get(offset);
res.json(daily);
};
+119
View File
@@ -0,0 +1,119 @@
const db = require('../db/db');
/* ── GET /api/search?q=...&type=...&limit=... ──────────────────────────── */
function search(req, res) {
const q = (req.query.q || '').trim();
if (!q || q.length < 2) return res.json({ results: [], total: 0 });
const type = req.query.type || 'all'; // all|lesson|course|file|question
const limit = Math.min(Number(req.query.limit) || 20, 50);
const uid = req.user.id;
const role = req.user.role;
const isTeacher = ['teacher', 'admin'].includes(role);
const pattern = `%${q}%`;
const results = [];
/* ── Lessons ── */
if (type === 'all' || type === 'lesson') {
const pubFilter = isTeacher ? '' : 'AND l.is_published = 1 AND c.is_published = 1';
const rows = db.prepare(`
SELECT l.id, l.title, l.order_index, c.title AS course_title,
c.id AS course_id, c.subject_slug, c.cover_emoji
FROM lessons l
JOIN courses c ON c.id = l.course_id
WHERE l.title LIKE ? ${pubFilter}
ORDER BY l.title LIMIT ?
`).all(pattern, limit);
for (const r of rows) {
results.push({
type: 'lesson',
id: r.id,
title: r.title,
subtitle: r.course_title,
extra: { courseId: r.course_id, subjectSlug: r.subject_slug, emoji: r.cover_emoji },
url: `/lesson?id=${r.id}`,
});
}
}
/* ── Courses ── */
if (type === 'all' || type === 'course') {
const pubFilter = isTeacher ? '' : 'AND c.is_published = 1';
const rows = db.prepare(`
SELECT c.id, c.title, c.description, c.subject_slug, c.cover_emoji
FROM courses c
WHERE (c.title LIKE ? OR c.description LIKE ?) ${pubFilter}
ORDER BY c.title LIMIT ?
`).all(pattern, pattern, limit);
for (const r of rows) {
results.push({
type: 'course',
id: r.id,
title: r.title,
subtitle: r.description ? r.description.slice(0, 80) : '',
extra: { subjectSlug: r.subject_slug, emoji: r.cover_emoji },
url: `/course?id=${r.id}`,
});
}
}
/* ── Files (access-controlled) ── */
if (type === 'all' || type === 'file') {
const accessFilter = isTeacher
? '(f.uploaded_by = ? OR f.is_public = 1)'
: `(
f.is_public = 1
OR EXISTS (SELECT 1 FROM file_access fa WHERE fa.file_id = f.id AND fa.type = 'user' AND fa.target_id = ?)
OR EXISTS (SELECT 1 FROM file_access fa JOIN class_members cm ON cm.class_id = fa.target_id AND cm.user_id = ? WHERE fa.file_id = f.id AND fa.type = 'class')
)`;
const accessArgs = isTeacher ? [uid] : [uid, uid];
const rows = db.prepare(`
SELECT f.id, f.title, f.original_name, f.subject_slug, f.mimetype,
u.name AS owner_name
FROM files f
JOIN users u ON u.id = f.uploaded_by
WHERE (f.title LIKE ? OR f.original_name LIKE ?) AND ${accessFilter}
ORDER BY f.title LIMIT ?
`).all(pattern, pattern, ...accessArgs, limit);
for (const r of rows) {
results.push({
type: 'file',
id: r.id,
title: r.title || r.original_name,
subtitle: r.owner_name || '',
extra: { subjectSlug: r.subject_slug, mimetype: r.mimetype },
url: `/library`,
});
}
}
/* ── Questions ── */
if (type === 'all' || type === 'question') {
if (isTeacher) {
const rows = db.prepare(`
SELECT q.id, q.text, s.slug AS subject_slug, t.name AS topic_name
FROM questions q
LEFT JOIN topics t ON t.id = q.topic_id
LEFT JOIN subjects s ON s.id = q.subject_id
WHERE q.text LIKE ?
ORDER BY q.id DESC LIMIT ?
`).all(pattern, limit);
for (const r of rows) {
results.push({
type: 'question',
id: r.id,
title: r.text.length > 100 ? r.text.slice(0, 100) + '…' : r.text,
subtitle: r.topic_name || '',
extra: { subjectSlug: r.subject_slug },
url: `/theory`,
});
}
}
}
res.json({ results: results.slice(0, limit), total: results.length });
}
module.exports = { search };
@@ -0,0 +1,606 @@
const db = require('../db/db');
const { pushNotif } = require('../utils/notifications');
const { onTestFinished, updateDailyGoal, awardXP, updateChallenges } = require('./gamificationController');
const { COMBO_BONUSES } = require('../constants');
/* ── Prepared statements (avoid re-parsing on every request) ──────────── */
const stmts = {
/* stats: 7 queries → 2 via CTE + json_group_array */
statsMega: db.prepare(`
WITH base AS (
SELECT ts.id, ts.score, ts.total, ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = @uid AND ts.status = 'completed'
),
hm AS (
SELECT date(ts.started_at) AS day, COUNT(*) AS cnt
FROM test_sessions ts
WHERE ts.user_id = @uid AND ts.started_at >= date('now', '-90 days')
GROUP BY day ORDER BY day
),
sd AS (
SELECT DISTINCT date(ts.started_at) AS d
FROM test_sessions ts WHERE ts.user_id = @uid
ORDER BY d DESC LIMIT 90
)
SELECT
(SELECT COALESCE(json_group_array(json_object(
'week', week, 'sessions', sessions, 'avg_pct', avg_pct)), '[]')
FROM (SELECT strftime('%Y-%W', finished_at) AS week,
COUNT(*) AS sessions,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct
FROM base WHERE finished_at >= date('now', '-84 days')
GROUP BY week ORDER BY week)) AS weekly,
(SELECT COALESCE(json_group_array(json_object('day', day, 'count', cnt)), '[]')
FROM hm) AS heatmap,
(SELECT COALESCE(json_group_array(json_object(
'slug', subject_slug, 'name', subject_name,
'sessions', sessions, 'avg_pct', avg_pct,
'total_correct', total_correct, 'total_questions', total_questions)), '[]')
FROM (SELECT subject_slug, subject_name, COUNT(*) AS sessions,
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct,
SUM(score) AS total_correct, SUM(total) AS total_questions
FROM base GROUP BY subject_slug ORDER BY sessions DESC)) AS bySubject,
(SELECT COALESCE(json_group_array(json_object(
'id', id, 'score', score, 'total', total,
'finished_at', finished_at, 'subject_slug', subject_slug)), '[]')
FROM (SELECT id, score, total, finished_at, subject_slug
FROM base ORDER BY finished_at DESC LIMIT 20)) AS trend,
(SELECT json_object(
'sessions', COUNT(*),
'correct', COALESCE(SUM(score), 0),
'questions',COALESCE(SUM(total), 0),
'avg_pct', COALESCE(AVG(CASE WHEN total>0 THEN score*100.0/total END), 0))
FROM base) AS totals,
(SELECT COALESCE(json_group_array(d), '[]') FROM sd) AS streakDays
`),
courseProgress: db.prepare(`
SELECT c.id, c.title, c.cover_emoji, c.subject_slug,
COUNT(l.id) AS total_lessons, COUNT(lp.id) AS done_lessons
FROM courses c JOIN lessons l ON l.course_id = c.id AND l.is_published = 1
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = @uid
WHERE c.is_published = 1 GROUP BY c.id HAVING done_lessons > 0
ORDER BY done_lessons * 1.0 / total_lessons DESC`),
sessionCount: db.prepare('SELECT COUNT(*) AS total FROM test_sessions WHERE user_id = ?'),
};
function shuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
const VALID_MODES = new Set(['exam', 'practice']);
/* ── POST /api/sessions ───────────────────────────────────────────────── */
function start(req, res, next) {
const { subject_slug, topic_id, test_id } = req.body;
const mode = req.body.mode || 'exam';
const count = Number(req.body.count) || 25;
if (!subject_slug) return res.status(400).json({ error: 'subject_slug is required' });
if (!VALID_MODES.has(mode)) return res.status(400).json({ error: 'mode must be exam or practice' });
if (!Number.isInteger(count) || count < 1 || count > 200)
return res.status(400).json({ error: 'count must be an integer between 1 and 200' });
const subject = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
if (!subject) return res.status(404).json({ error: 'Subject not found' });
let ids;
let testTimeLimitSec = null;
if (test_id) {
const tRow = db.prepare('SELECT time_limit FROM tests WHERE id = ?').get(Number(test_id));
if (tRow?.time_limit) testTimeLimitSec = tRow.time_limit * 60;
const tq = db.prepare(
'SELECT question_id AS id FROM test_questions WHERE test_id = ? ORDER BY order_index'
).all(Number(test_id));
if (!tq.length) return res.status(404).json({ error: 'Test has no questions' });
ids = shuffle(tq.map(r => r.id));
} else {
const rows = topic_id
? db.prepare('SELECT id FROM questions WHERE subject_id = ? AND topic_id = ?').all(subject.id, Number(topic_id))
: db.prepare('SELECT id FROM questions WHERE subject_id = ?').all(subject.id);
if (!rows.length) return res.status(404).json({ error: 'No questions found' });
ids = shuffle(rows.map(r => r.id)).slice(0, count);
}
const total = ids.length;
const createSession = db.transaction(() => {
const { lastInsertRowid: session_id } = db.prepare(
'INSERT INTO test_sessions (user_id, subject_id, mode, total) VALUES (?, ?, ?, ?)'
).run(req.user.id, subject.id, mode, total);
const insertSQ = db.prepare(
'INSERT INTO session_questions (session_id, question_id, order_index) VALUES (?, ?, ?)'
);
ids.forEach((qid, i) => insertSQ.run(session_id, qid, i));
return session_id;
});
try {
const session_id = createSession();
const questions = loadQuestionsForSession(ids);
res.status(201).json({ session_id, total, mode, questions, time_limit_sec: testTimeLimitSec });
} catch (err) {
next(err);
}
}
/* ── POST /api/sessions/:id/answer ───────────────────────────────────── */
function answer(req, res) {
const session_id = Number(req.params.id);
const { question_id, option_id, time_spent_sec, answer_text, chosen_options } = req.body;
const session = db.prepare(
'SELECT id, mode, status FROM test_sessions WHERE id = ? AND user_id = ?'
).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
if (session.status !== 'in_progress')
return res.status(400).json({ error: 'Session already finished' });
// Verify question belongs to this session — prevents answering questions from other sessions
const sq = db.prepare(
'SELECT 1 FROM session_questions WHERE session_id = ? AND question_id = ?'
).get(session_id, question_id);
if (!sq) return res.status(400).json({ error: 'Question not in this session' });
const q = db.prepare('SELECT id, type, correct_text FROM questions WHERE id = ?').get(question_id);
if (!q) return res.status(400).json({ error: 'Invalid question' });
let isCorrect = 0;
let chosenOptionId = null;
let storedAnswerText = null;
if (q.type === 'short_answer') {
const userAns = String(answer_text || '').trim().toLowerCase().replace(/\s+/g, ' ');
const correct = String(q.correct_text || '').trim().toLowerCase().replace(/\s+/g, ' ');
isCorrect = userAns === correct ? 1 : 0;
storedAnswerText = String(answer_text || '').trim();
} else if (q.type === 'multi') {
const selected = Array.isArray(chosen_options) ? chosen_options.map(Number) : [];
const allOpts = db.prepare('SELECT id, is_correct FROM options WHERE question_id = ?').all(question_id);
const correctIds = allOpts.filter(o => o.is_correct).map(o => o.id);
const wrongIds = allOpts.filter(o => !o.is_correct).map(o => o.id);
isCorrect = (
correctIds.every(id => selected.includes(id)) &&
wrongIds.every(id => !selected.includes(id))
) ? 1 : 0;
storedAnswerText = JSON.stringify(selected);
} else if (q.type === 'matching') {
const pairs = (() => { try { return JSON.parse(answer_text || '{}'); } catch (e) { console.error('[answer] matching parse error:', e.message); return {}; } })();
const allOpts = db.prepare('SELECT id, match_pair FROM options WHERE question_id = ?').all(question_id);
isCorrect = allOpts.length > 0 && allOpts.every(opt => pairs[String(opt.id)] === opt.match_pair) ? 1 : 0;
storedAnswerText = answer_text;
} else {
// single / true_false
const opt = db.prepare(
'SELECT id, is_correct FROM options WHERE id = ? AND question_id = ?'
).get(option_id, question_id);
if (!opt) return res.status(400).json({ error: 'Invalid option' });
isCorrect = opt.is_correct;
chosenOptionId = opt.id;
}
db.prepare(`
INSERT INTO user_answers (session_id, question_id, chosen_option_id, answer_text, is_correct, time_spent_sec)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id, question_id)
DO UPDATE SET chosen_option_id = excluded.chosen_option_id,
answer_text = excluded.answer_text,
is_correct = excluded.is_correct,
time_spent_sec = excluded.time_spent_sec,
answered_at = datetime('now')
`).run(session_id, question_id, chosenOptionId, storedAnswerText, isCorrect, time_spent_sec ?? null);
const response = { is_correct: isCorrect === 1 };
if (session.mode === 'practice') {
if (q.type === 'short_answer') {
response.correct_text = q.correct_text;
} else {
response.correct_options = db.prepare(
'SELECT id, text FROM options WHERE question_id = ? AND is_correct = 1'
).all(question_id);
}
}
res.json(response);
}
/* ── POST /api/sessions/:id/finish ───────────────────────────────────── */
function finish(req, res) {
const session_id = Number(req.params.id);
const session = db.prepare(
'SELECT * FROM test_sessions WHERE id = ? AND user_id = ?'
).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
// Atomically mark as completed — guard against concurrent finish() calls
let score;
try {
score = db.transaction(() => {
const { score: s } = db.prepare(
'SELECT COUNT(*) AS score FROM user_answers WHERE session_id = ? AND is_correct = 1'
).get(session_id);
const upd = db.prepare(
"UPDATE test_sessions SET status = 'completed', score = ?, finished_at = datetime('now') WHERE id = ? AND status = 'in_progress'"
).run(s, session_id);
if (upd.changes === 0) throw Object.assign(new Error('already_finished'), { code: 'ALREADY_FINISHED' });
return s;
})();
} catch (e) {
if (e.code === 'ALREADY_FINISHED') return res.status(400).json({ error: 'Session already finished' });
throw e;
}
// Compute max combo (consecutive correct answers in order)
let maxCombo = 0;
try {
const ansRows = db.prepare(
'SELECT is_correct FROM user_answers WHERE session_id = ? ORDER BY rowid'
).all(session_id);
let streak = 0;
for (const a of ansRows) {
streak = a.is_correct ? streak + 1 : 0;
if (streak > maxCombo) maxCombo = streak;
}
} catch (e) { console.error('[finish] combo calc:', e.message); }
// Gamification: award XP, update streak, check achievements
try {
const timeSec = Math.round((Date.now() - new Date(session.started_at).getTime()) / 1000);
// Check if linked to a test with time_limit
let testTimeLimitSec = null;
try {
const tl = db.prepare(`
SELECT t.time_limit FROM assignment_sessions ases
JOIN assignments a ON a.id = ases.assignment_id
JOIN tests t ON t.id = a.test_id
WHERE ases.session_id = ?
`).get(session_id);
if (tl?.time_limit) testTimeLimitSec = tl.time_limit * 60;
} catch (e) { console.error('[finish] time_limit fetch:', e.message); }
// Combo bonus XP (thresholds from constants)
let comboBonus = 0;
for (const [min, xp] of COMBO_BONUSES) {
if (maxCombo >= min) { comboBonus = xp; break; }
}
onTestFinished(req.user.id, score, session.total, timeSec, testTimeLimitSec);
if (comboBonus > 0) {
try { awardXP(req.user.id, comboBonus, `Комбо x${maxCombo}`); } catch (e) { console.error('[finish] comboBonus awardXP:', e.message); }
}
updateDailyGoal(req.user.id, 1, score * 10 + 50 + comboBonus);
// Update personal challenges
try {
const subj = db.prepare('SELECT s.slug FROM subjects s WHERE s.id = ?').get(session.subject_id);
const topicIds = db.prepare(`
SELECT DISTINCT q.topic_id FROM session_questions sq
JOIN questions q ON q.id = sq.question_id
WHERE sq.session_id = ? AND q.topic_id IS NOT NULL
`).all(session_id).map(r => r.topic_id);
const slug = subj ? subj.slug : null;
for (const tid of (topicIds.length ? topicIds : [null])) {
updateChallenges(req.user.id, score, session.total, slug, tid);
}
} catch (e) { console.error('[finish] updateChallenges:', e.message); }
} catch (e) { console.error('[finish] gamification:', e.message); }
// Notify teacher if session linked to a class assignment
try {
const link = db.prepare(`
SELECT a.title, COALESCE(c.teacher_id, a.created_by) AS teacher_id, u.name AS student_name
FROM assignment_sessions ass
JOIN assignments a ON a.id = ass.assignment_id
LEFT JOIN classes c ON c.id = a.class_id
JOIN users u ON u.id = ?
WHERE ass.session_id = ?
`).get(req.user.id, session_id);
if (link) {
const pct = Math.round((score / session.total) * 100);
pushNotif(link.teacher_id, 'session', `«${link.student_name}» сдал «${link.title}» — ${pct}%`, '/classes');
}
} catch (e) { console.error('[finish] pushNotif teacher:', e.message); }
res.json({
session_id,
score,
total: session.total,
percent: session.total ? Math.round((score / session.total) * 100) : 0,
time_sec: Math.round((Date.now() - new Date(session.started_at)) / 1000),
maxCombo,
review: buildReview(session_id),
});
}
/* ── GET /api/sessions/:id/result ────────────────────────────────────── */
function result(req, res) {
const session_id = Number(req.params.id);
const session = db.prepare(
'SELECT * FROM test_sessions WHERE id = ? AND user_id = ?'
).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
if (session.status !== 'completed')
return res.status(400).json({ error: 'Session not finished yet' });
// Check if session is linked to an assignment using a test with show_answers setting
let show_answers = 1;
try {
const assnSess = db.prepare(`
SELECT t.show_answers FROM assignment_sessions ases
JOIN assignments a ON a.id = ases.assignment_id
JOIN tests t ON t.id = a.test_id
WHERE ases.session_id = ?
`).get(session_id);
if (assnSess) show_answers = assnSess.show_answers;
} catch {}
// Compute max combo for display
let maxCombo = 0;
try {
const ansRows = db.prepare(
'SELECT is_correct FROM user_answers WHERE session_id = ? ORDER BY rowid'
).all(session_id);
let streak = 0;
for (const a of ansRows) {
streak = a.is_correct ? streak + 1 : 0;
if (streak > maxCombo) maxCombo = streak;
}
} catch {}
res.json({
session_id,
score: session.score,
total: session.total,
percent: session.total ? Math.round((session.score / session.total) * 100) : 0,
show_answers,
maxCombo,
review: buildReview(session_id),
});
}
/* ── GET /api/sessions/history ───────────────────────────────────────── */
function history(req, res) {
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));
const cursor = Number(req.query.cursor) || 0;
// Cursor-based: if cursor provided, use it; otherwise fall back to offset pagination
if (cursor) {
const rows = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.user_id = ? AND ts.id < ?
ORDER BY ts.id DESC
LIMIT ?
`).all(req.user.id, cursor, limit);
const nextCursor = rows.length === limit ? rows[rows.length - 1].id : null;
return res.json({ rows, 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 test_sessions WHERE user_id = ?').get(req.user.id);
const rows = db.prepare(`
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
ts.started_at, ts.finished_at,
s.slug AS subject_slug, s.name AS subject_name
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 ? OFFSET ?
`).all(req.user.id, limit, offset);
res.json({ rows, total, page, limit });
}
/* ── helpers ─────────────────────────────────────────────────────────── */
function _placeholders(n) { return Array(n).fill('?').join(','); }
function loadQuestionsForSession(ids) {
if (!ids.length) return [];
const ph = _placeholders(ids.length);
const questions = db.prepare(
`SELECT id, text, type, difficulty FROM questions WHERE id IN (${ph})`
).all(...ids);
const allOptions = db.prepare(
`SELECT question_id, id, text, match_pair FROM options WHERE question_id IN (${ph}) ORDER BY question_id, order_index`
).all(...ids);
const optsByQ = {};
for (const o of allOptions) (optsByQ[o.question_id] ??= []).push(o);
// Restore caller-expected order
const qMap = {};
for (const q of questions) qMap[q.id] = q;
return ids.map(id => {
const q = qMap[id];
if (!q) return null;
q.options = q.type !== 'short_answer' ? (optsByQ[id] || []) : [];
return q;
}).filter(Boolean);
}
function buildReview(session_id) {
const sqRows = db.prepare(
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
).all(session_id);
if (!sqRows.length) return [];
const ids = sqRows.map(r => r.question_id);
const ph = _placeholders(ids.length);
const questions = db.prepare(
`SELECT id, text, type, explanation, correct_text FROM questions WHERE id IN (${ph})`
).all(...ids);
const answers = db.prepare(
`SELECT question_id, chosen_option_id, answer_text, is_correct FROM user_answers WHERE session_id = ? AND question_id IN (${ph})`
).all(session_id, ...ids);
const options = db.prepare(
`SELECT question_id, id, text, is_correct, match_pair FROM options WHERE question_id IN (${ph}) ORDER BY question_id, order_index`
).all(...ids);
const ansMap = {};
for (const a of answers) ansMap[a.question_id] = a;
const optsByQ = {};
for (const o of options) (optsByQ[o.question_id] ??= []).push(o);
const qMap = {};
for (const q of questions) qMap[q.id] = q;
return ids.map(id => {
const q = qMap[id];
const ua = ansMap[id];
return {
...q,
options: optsByQ[id] || [],
chosen_option_id: ua?.chosen_option_id ?? null,
answer_text: ua?.answer_text ?? null,
is_correct: ua ? ua.is_correct === 1 : null,
};
});
}
/* ── GET /api/sessions/weak-topics ───────────────────────────────────── */
function weakTopics(req, res) {
const rows = db.prepare(`
SELECT t.id AS topic_id,
t.name AS topic,
s.name AS subject_name,
s.slug AS subject_slug,
COUNT(ua.id) AS total,
SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS wrong,
ROUND(
CAST(SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS REAL)
/ COUNT(ua.id) * 100
, 0) AS error_pct
FROM user_answers ua
JOIN test_sessions ts ON ts.id = ua.session_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 = q.subject_id
WHERE ts.user_id = ? AND ts.status = 'completed' AND q.topic_id IS NOT NULL
GROUP BY q.topic_id
HAVING total >= 2
ORDER BY error_pct DESC, wrong DESC
LIMIT 8
`).all(req.user.id);
res.json(rows);
}
/* ── GET /api/sessions/:id/questions ── resume existing session ─────── */
function getSessionQuestions(req, res) {
const session_id = Number(req.params.id);
const session = db.prepare(`
SELECT ts.id, ts.mode, ts.total, ts.status, ts.started_at, s.slug AS subject_slug
FROM test_sessions ts
LEFT JOIN subjects s ON s.id = ts.subject_id
WHERE ts.id = ? AND ts.user_id = ?
`).get(session_id, req.user.id);
if (!session) return res.status(404).json({ error: 'Session not found' });
if (session.status !== 'in_progress') return res.status(400).json({ error: 'Session already finished' });
const ids = db.prepare(
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
).all(session_id).map(r => r.question_id);
// Resolve time limit from linked test (if any)
let time_limit_sec = null;
try {
const tl = db.prepare(`
SELECT t.time_limit FROM assignment_sessions ases
JOIN assignments a ON a.id = ases.assignment_id
JOIN tests t ON t.id = a.test_id
WHERE ases.session_id = ? AND t.time_limit IS NOT NULL
`).get(session_id);
if (tl?.time_limit) time_limit_sec = tl.time_limit * 60;
} catch {}
const questions = loadQuestionsForSession(ids);
res.json({
session_id,
total: session.total,
mode: session.mode,
subject_slug: session.subject_slug,
questions,
time_limit_sec,
started_at: session.started_at,
});
}
/* ── GET /api/sessions/stats ── student dashboard charts ────────────── */
function stats(req, res) {
const uid = req.user.id;
// 2 queries instead of 7: mega-CTE returns all session data as JSON columns
const row = stmts.statsMega.get({ uid });
const weekly = JSON.parse(row.weekly);
const heatmap = JSON.parse(row.heatmap);
const bySubject = JSON.parse(row.bySubject);
const trend = JSON.parse(row.trend).reverse();
const totals = JSON.parse(row.totals);
const dayKeys = new Set(JSON.parse(row.streakDays));
const courseProgress = stmts.courseProgress.all({ uid });
// Streak calculation (JS side, uses pre-fetched day set)
let streak = 0;
const now = new Date();
for (let i = 0; i <= 90; i++) {
const d = new Date(now);
d.setDate(d.getDate() - i);
const key = d.toISOString().slice(0, 10);
if (dayKeys.has(key)) streak++;
else if (i > 0) break;
}
res.json({
weekly: weekly.map(r => ({ week: r.week, sessions: r.sessions, avgPct: Math.round(r.avg_pct || 0) })),
heatmap: heatmap.map(r => ({ day: r.day, count: r.count })),
bySubject: bySubject.map(r => ({
slug: r.slug, name: r.name, sessions: r.sessions,
avgPct: Math.round(r.avg_pct || 0),
correct: r.total_correct, questions: r.total_questions,
})),
trend: trend.map(r => ({
pct: r.total > 0 ? Math.round(r.score * 100 / r.total) : 0,
date: r.finished_at, subject: r.subject_slug,
})),
streak,
totals: {
sessions: totals.sessions || 0,
correct: totals.correct || 0,
questions:totals.questions|| 0,
avgPct: Math.round(totals.avg_pct || 0),
},
courseProgress: courseProgress.map(r => ({
id: r.id, title: r.title, emoji: r.cover_emoji,
subjectSlug: r.subject_slug,
done: r.done_lessons, total: r.total_lessons,
pct: r.total_lessons > 0 ? Math.round(r.done_lessons * 100 / r.total_lessons) : 0,
})),
});
}
module.exports = { start, answer, finish, result, history, weakTopics, getSessionQuestions, stats };
@@ -0,0 +1,32 @@
const db = require('../db/db');
/* ── GET /api/settings/sims ─────────────────────────────────────────────── */
function getSimSettings(req, res) {
const rows = db.prepare(`SELECT key, value FROM app_settings WHERE key IN ('sim_module_disabled','sim_disabled_ids')`).all();
const map = Object.fromEntries(rows.map(r => [r.key, r.value]));
res.json({
module_disabled: map['sim_module_disabled'] === '1',
disabled_ids: JSON.parse(map['sim_disabled_ids'] || '[]'),
});
}
/* ── PUT /api/settings/sims ─────────────────────────────────────────────── */
function updateSimSettings(req, res) {
const { module_disabled, disabled_ids } = req.body;
if (module_disabled !== undefined) {
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('sim_module_disabled', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
.run(module_disabled ? '1' : '0');
}
if (Array.isArray(disabled_ids)) {
db.prepare(`INSERT INTO app_settings (key, value) VALUES ('sim_disabled_ids', ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`)
.run(JSON.stringify(disabled_ids));
}
res.json({ ok: true });
}
module.exports = { getSimSettings, updateSimSettings };
+194
View File
@@ -0,0 +1,194 @@
const db = require('../db/db');
/* ═══════════════════════════════════════════════════════════════════════
Shop — Items, Purchases, Coins
═══════════════════════════════════════════════════════════════════════ */
/* GET /api/shop/items — list all active shop items + owned status */
function getItems(req, res) {
const userId = req.user.id;
const items = db.prepare(`
SELECT si.*,
(SELECT 1 FROM user_purchases up WHERE up.item_id = si.id AND up.user_id = ?) AS owned
FROM shop_items si
WHERE si.is_active = 1
ORDER BY si.price
`).all(userId);
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
res.json({ items, coins: (user && user.coins) || 0 });
}
/* POST /api/shop/items/:id/purchase — buy an item (atomic transaction) */
function purchaseItem(req, res) {
const userId = req.user.id;
const itemId = Number(req.params.id);
const item = db.prepare('SELECT * FROM shop_items WHERE id = ? AND is_active = 1').get(itemId);
if (!item) return res.status(404).json({ error: 'Предмет не найден' });
const alreadyOwned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
if (alreadyOwned) return res.status(400).json({ error: 'Вы уже купили этот предмет' });
// Atomic: check balance + deduct + insert purchase in one transaction
const doPurchase = db.transaction(() => {
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
if (!user || (user.coins || 0) < item.price) return { err: 'Недостаточно монет' };
db.prepare('UPDATE users SET coins = coins - ? WHERE id = ?').run(item.price, userId);
db.prepare('INSERT INTO user_purchases (user_id, item_id) VALUES (?, ?)').run(userId, itemId);
const updated = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
return { coins: (updated && updated.coins) || 0 };
});
const result = doPurchase();
if (result.err) return res.status(400).json({ error: result.err });
res.json({ ok: true, coins: result.coins, item });
}
/* GET /api/shop/purchases — list user's purchases with item details */
function getPurchases(req, res) {
const rows = db.prepare(`
SELECT up.id AS purchase_id, up.purchased_at, si.*
FROM user_purchases up
JOIN shop_items si ON si.id = up.item_id
WHERE up.user_id = ?
ORDER BY up.purchased_at DESC
`).all(req.user.id);
res.json(rows);
}
/* GET /api/shop/coins — return user's coin balance */
function getCoins(req, res) {
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(req.user.id);
res.json({ coins: (user && user.coins) || 0 });
}
/* GET /api/shop/my-active — return user's active cosmetics */
function getMyActive(req, res) {
const u = db.prepare('SELECT avatar_frame, active_title, active_effect FROM users WHERE id = ?').get(req.user.id);
if (!u) return res.json({});
// Resolve full data for each active item
const result = { frame: null, title: null, effect: null };
// Frame from avatar_frame (gamification frames) — handled separately
// Shop frame override
if (u.avatar_frame && u.avatar_frame !== 'default') {
result.frame = { id: u.avatar_frame };
}
if (u.active_title) {
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_title);
if (item) try { result.title = JSON.parse(item.data); } catch {}
}
if (u.active_effect) {
const item = db.prepare('SELECT data FROM shop_items WHERE id = ?').get(u.active_effect);
if (item) try { result.effect = JSON.parse(item.data); } catch {}
}
res.json(result);
}
/* POST /api/shop/activate — activate a purchased item (or deactivate with itemId=null) */
function activateItem(req, res) {
const userId = req.user.id;
const { itemId } = req.body;
// Deactivate: pass itemId = null and type
if (!itemId) {
const { type } = req.body;
if (type === 'title') db.prepare('UPDATE users SET active_title = NULL WHERE id = ?').run(userId);
if (type === 'effect') db.prepare('UPDATE users SET active_effect = NULL WHERE id = ?').run(userId);
if (type === 'frame') db.prepare("UPDATE users SET avatar_frame = 'default' WHERE id = ?").run(userId);
return res.json({ ok: true });
}
const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(itemId);
if (!item) return res.status(404).json({ error: 'Предмет не найден' });
const owned = db.prepare('SELECT 1 FROM user_purchases WHERE user_id = ? AND item_id = ?').get(userId, itemId);
if (!owned) return res.status(403).json({ error: 'Предмет не куплен' });
let data;
try { data = JSON.parse(item.data); } catch { data = {}; }
if (item.type === 'frame') db.prepare('UPDATE users SET avatar_frame = ? WHERE id = ?').run('shop_' + itemId, userId);
if (item.type === 'title') db.prepare('UPDATE users SET active_title = ? WHERE id = ?').run(itemId, userId);
if (item.type === 'effect') db.prepare('UPDATE users SET active_effect = ? WHERE id = ?').run(itemId, userId);
res.json({ ok: true, type: item.type, data });
}
/* ═══════════════════════════════════════════════════════════════════════
Admin — CRUD shop items, award coins, stats
═══════════════════════════════════════════════════════════════════════ */
/* GET /api/shop/admin/items — all items (including inactive) */
function adminGetItems(_req, res) {
const items = db.prepare(`
SELECT si.*,
(SELECT COUNT(*) FROM user_purchases up WHERE up.item_id = si.id) AS sold_count
FROM shop_items si ORDER BY si.id
`).all();
res.json(items);
}
/* POST /api/shop/admin/items — create item */
function adminCreateItem(req, res) {
const { name, description, type, category, price, data, icon, is_active } = req.body;
if (!name || !type || price == null) return res.status(400).json({ error: 'name, type, price required' });
const r = db.prepare(
'INSERT INTO shop_items (name, description, type, category, price, data, icon, is_active) VALUES (?,?,?,?,?,?,?,?)'
).run(name, description || '', type, category || 'cosmetic', price, data || '{}', icon || 'star', is_active ?? 1);
res.json({ ok: true, id: r.lastInsertRowid });
}
/* PUT /api/shop/admin/items/:id — update item */
function adminUpdateItem(req, res) {
const id = Number(req.params.id);
const item = db.prepare('SELECT * FROM shop_items WHERE id = ?').get(id);
if (!item) return res.status(404).json({ error: 'Item not found' });
const { name, description, type, category, price, data, icon, is_active } = req.body;
db.prepare(`UPDATE shop_items SET
name=COALESCE(?,name), description=COALESCE(?,description), type=COALESCE(?,type),
category=COALESCE(?,category), price=COALESCE(?,price), data=COALESCE(?,data),
icon=COALESCE(?,icon), is_active=COALESCE(?,is_active) WHERE id=?`
).run(name, description, type, category, price, data, icon, is_active, id);
res.json({ ok: true });
}
/* DELETE /api/shop/admin/items/:id — delete item */
function adminDeleteItem(req, res) {
const id = Number(req.params.id);
db.prepare('DELETE FROM user_purchases WHERE item_id = ?').run(id);
db.prepare('DELETE FROM shop_items WHERE id = ?').run(id);
res.json({ ok: true });
}
/* POST /api/shop/admin/award-coins — award coins to user */
function adminAwardCoins(req, res) {
const { userId, amount, reason } = req.body;
if (!userId || !amount || amount < 0) return res.status(400).json({ error: 'userId and positive amount required' });
db.prepare('UPDATE users SET coins = coins + ? WHERE id = ?').run(amount, userId);
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(userId);
res.json({ ok: true, coins: user?.coins || 0 });
}
/* GET /api/shop/admin/stats — shop stats */
function adminShopStats(_req, res) {
const totalItems = db.prepare('SELECT COUNT(*) as c FROM shop_items').get().c;
const activeItems = db.prepare('SELECT COUNT(*) as c FROM shop_items WHERE is_active=1').get().c;
const totalPurchases = db.prepare('SELECT COUNT(*) as c FROM user_purchases').get().c;
const totalCoinsInCirculation = db.prepare('SELECT COALESCE(SUM(coins),0) as c FROM users').get().c;
const topItems = db.prepare(`
SELECT si.name, si.price, COUNT(up.id) as sold
FROM shop_items si LEFT JOIN user_purchases up ON up.item_id = si.id
GROUP BY si.id ORDER BY sold DESC LIMIT 5
`).all();
res.json({ totalItems, activeItems, totalPurchases, totalCoinsInCirculation, topItems });
}
module.exports = {
getItems, purchaseItem, getPurchases, getCoins, getMyActive, activateItem,
adminGetItems, adminCreateItem, adminUpdateItem, adminDeleteItem, adminAwardCoins, adminShopStats
};
@@ -0,0 +1,318 @@
const db = require('../db/db');
const path = require('path');
const fs = require('fs');
const { pushNotif, pushParentNotif } = require('../utils/notifications');
const { UPLOADS_DIR } = require('../config');
const { checkMagicBytes } = require('../utils/magic');
/* ── POST /api/submissions (student) ─────────────────────────────────── */
function submit(req, res) {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const { assignment_id, class_id, message } = req.body;
const student_id = req.user.id;
if (!class_id) return res.status(400).json({ error: 'class_id required' });
const member = db.prepare(
'SELECT 1 FROM class_members WHERE class_id = ? AND user_id = ?'
).get(Number(class_id), student_id);
if (!member) return res.status(403).json({ error: 'Not a member of this class' });
if (assignment_id) {
const assign = db.prepare(
'SELECT id FROM assignments WHERE id = ? AND class_id = ?'
).get(Number(assignment_id), Number(class_id));
if (!assign) return res.status(400).json({ error: 'Assignment not found in class' });
}
// Magic bytes verification — reject spoofed MIME types
const uploadedPath = path.resolve(UPLOADS_DIR, req.file.filename);
if (!checkMagicBytes(uploadedPath, req.file.mimetype)) {
try { fs.unlinkSync(uploadedPath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
}
let r;
try {
r = db.prepare(`
INSERT INTO submissions
(class_id, assignment_id, student_id, original_name, stored_name, mimetype, size, message)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
Number(class_id),
assignment_id ? Number(assignment_id) : null,
student_id,
req.file.originalname,
req.file.filename,
req.file.mimetype,
req.file.size,
message?.trim() || null
);
} catch (err) {
const fp = path.resolve(UPLOADS_DIR, req.file.filename);
if (fp.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch {} }
throw err;
}
// Notify teacher that a student submitted work
try {
const cls = db.prepare('SELECT teacher_id, name FROM classes WHERE id = ?').get(Number(class_id));
if (cls?.teacher_id) {
const assignTitle = assignment_id
? db.prepare('SELECT title FROM assignments WHERE id = ?').get(Number(assignment_id))?.title
: null;
const studentName = req.user.name || req.user.email;
const msg = assignTitle
? `«${studentName}» сдал работу по заданию «${assignTitle}»`
: `«${studentName}» прикрепил работу в классе «${cls.name}»`;
pushNotif(cls.teacher_id, 'submission', msg, '/classes');
}
} catch (e) { console.error('[submissions] notify teacher:', e.message); }
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/submissions/my (student) ───────────────────────────────── */
function getMySubmissions(req, res) {
const rows = db.prepare(`
SELECT s.id, s.class_id, s.assignment_id, s.original_name, s.size,
s.mimetype, s.message, s.status, s.teacher_note, s.grade, s.submitted_at,
a.title AS assignment_title
FROM submissions s
LEFT JOIN assignments a ON a.id = s.assignment_id
WHERE s.student_id = ?
ORDER BY s.submitted_at DESC
`).all(req.user.id);
res.json(rows);
}
/* ── GET /api/submissions?class_id=X (teacher/admin) ─────────────────── */
function getClassSubmissions(req, res) {
const { class_id } = req.query;
if (!class_id) return res.status(400).json({ error: 'class_id required' });
if (req.user.role === 'teacher') {
const cls = db.prepare('SELECT teacher_id FROM classes WHERE id = ?').get(Number(class_id));
if (!cls) return res.status(404).json({ error: 'Class not found' });
if (cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
}
const rows = db.prepare(`
SELECT s.id, s.class_id, s.assignment_id, s.original_name, s.size,
s.mimetype, s.message, s.status, s.teacher_note, s.grade, s.submitted_at,
u.name AS student_name, u.email AS student_email,
a.title AS assignment_title
FROM submissions s
JOIN users u ON u.id = s.student_id
LEFT JOIN assignments a ON a.id = s.assignment_id
WHERE s.class_id = ?
ORDER BY s.submitted_at DESC
`).all(Number(class_id));
res.json(rows);
}
/* ── PATCH /api/submissions/:id (teacher/admin) ──────────────────────── */
function reviewSubmission(req, res) {
const sub = db.prepare(`
SELECT s.*, c.teacher_id FROM submissions s
JOIN classes c ON c.id = s.class_id WHERE s.id = ?
`).get(Number(req.params.id));
if (!sub) return res.status(404).json({ error: 'Submission not found' });
if (req.user.role === 'teacher' && sub.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const VALID_STATUSES = ['new', 'reviewed', 'revision', 'resubmitted', 'accepted'];
const { status, teacher_note, grade } = req.body;
if (status && !VALID_STATUSES.includes(status))
return res.status(400).json({ error: 'Invalid status' });
if (grade !== undefined && grade !== null) {
const g = Number(grade);
if (!Number.isInteger(g) || g < 0 || g > 100)
return res.status(400).json({ error: 'Grade must be integer 0-100' });
}
const gradeVal = grade === undefined ? sub.grade :
(grade === null || grade === '') ? null : Number(grade);
const noteVal = teacher_note !== undefined ? (teacher_note?.trim() || null) : sub.teacher_note;
const statusVal = status || sub.status;
const reviewedAt = (status === 'reviewed' || status === 'accepted') ? new Date().toISOString() : sub.reviewed_at;
db.prepare(`
UPDATE submissions SET status=?, teacher_note=?, grade=?, reviewed_at=? WHERE id=?
`).run(statusVal, noteVal, gradeVal, reviewedAt, sub.id);
// Notify student
try {
const assignTitle = sub.assignment_id
? db.prepare('SELECT title FROM assignments WHERE id = ?').get(sub.assignment_id)?.title
: null;
const isGraded = gradeVal !== undefined && gradeVal !== null;
if (status === 'revision') {
const msg = assignTitle
? `Работа «${assignTitle}» отправлена на доработку.${noteVal ? ' Комментарий: ' + noteVal : ''}`
: `Ваша работа отправлена на доработку.`;
pushNotif(sub.student_id, 'revision', msg, '/homework');
} else if (status === 'accepted' || (status === 'reviewed' && sub.status !== 'reviewed')) {
const gradeText = isGraded ? ` Оценка: ${gradeVal}/100` : '';
const statusText = status === 'accepted' ? 'принята' : 'проверена';
const msg = assignTitle
? `Ваша работа «${assignTitle}» ${statusText}.${gradeText}`
: `Ваша работа ${statusText}.${gradeText}`;
pushNotif(sub.student_id, 'grade', msg, '/homework');
} else if (isGraded && !status) {
const msg = assignTitle
? `Оценка за «${assignTitle}»: ${gradeVal}/100`
: `Оценка за работу: ${gradeVal}/100`;
pushNotif(sub.student_id, 'grade', msg, '/homework');
}
// Notify parents
if (isGraded || status === 'accepted' || status === 'reviewed') {
const gradeText = isGraded ? ` Оценка: ${gradeVal}/100` : '';
const parentMsg = assignTitle
? `Работа «${assignTitle}» проверена.${gradeText}`
: `Работа проверена.${gradeText}`;
pushParentNotif(sub.student_id, 'grade', parentMsg);
}
} catch (e) { console.error('[submissions] notify student grade:', e.message); }
res.json({ ok: true });
}
/* ── GET /api/submissions/:id/download ────────────────────────────────── */
function downloadSubmission(req, res) {
const sub = db.prepare(`
SELECT s.*, c.teacher_id FROM submissions s
JOIN classes c ON c.id = s.class_id WHERE s.id = ?
`).get(Number(req.params.id));
if (!sub) return res.status(404).json({ error: 'Submission not found' });
const uid = req.user.id;
const isTeacher = ['teacher', 'admin'].includes(req.user.role);
if (!isTeacher && sub.student_id !== uid) return res.status(403).json({ error: 'Forbidden' });
if (req.user.role === 'teacher' && sub.teacher_id !== uid) return res.status(403).json({ error: 'Forbidden' });
const filePath = path.resolve(UPLOADS_DIR, sub.stored_name);
if (!filePath.startsWith(UPLOADS_DIR + path.sep))
return res.status(400).json({ error: 'Invalid path' });
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File missing' });
const encoded = encodeURIComponent(sub.original_name);
res.setHeader('Content-Disposition', `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`);
res.setHeader('Content-Type', sub.mimetype || 'application/octet-stream');
res.sendFile(filePath);
}
/* ── DELETE /api/submissions/:id ───────────────────────────────────────── */
function deleteSubmission(req, res) {
const sub = db.prepare('SELECT s.*, c.teacher_id FROM submissions s JOIN classes c ON c.id = s.class_id WHERE s.id = ?')
.get(Number(req.params.id));
if (!sub) return res.status(404).json({ error: 'Submission not found' });
const isOwner = sub.student_id === req.user.id;
const isAdmin = req.user.role === 'admin';
const isTeacher = req.user.role === 'teacher' && sub.teacher_id === req.user.id;
if (!isOwner && !isAdmin && !isTeacher) return res.status(403).json({ error: 'Forbidden' });
// Students can't delete reviewed/accepted submissions; teachers/admin can
if (isOwner && !isAdmin && !isTeacher && ['reviewed', 'accepted'].includes(sub.status))
return res.status(400).json({ error: 'Cannot delete a reviewed submission' });
// Audit log — record who deleted what
const studentName = db.prepare('SELECT name FROM users WHERE id = ?').get(sub.student_id)?.name || '';
db.prepare(`
INSERT INTO submission_log (submission_id, class_id, assignment_id, student_id, student_name,
original_name, status, grade, teacher_note, submitted_at, action, deleted_by, deleted_by_role)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'deleted', ?, ?)
`).run(sub.id, sub.class_id, sub.assignment_id, sub.student_id, studentName,
sub.original_name, sub.status, sub.grade, sub.teacher_note, sub.submitted_at,
req.user.id, req.user.role);
const filePath = path.resolve(UPLOADS_DIR, sub.stored_name);
if (filePath.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(filePath); } catch {} }
db.prepare('DELETE FROM submissions WHERE id = ?').run(sub.id);
res.json({ ok: true });
}
/* ── POST /api/submissions/:id/resubmit (student — only if revision) ──── */
function resubmit(req, res) {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const sub = db.prepare('SELECT * FROM submissions WHERE id = ?').get(Number(req.params.id));
if (!sub) return res.status(404).json({ error: 'Submission not found' });
if (sub.student_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
if (sub.status !== 'revision') return res.status(400).json({ error: 'Resubmit only allowed for revision status' });
// Magic bytes verification on resubmit
const resubPath = path.resolve(UPLOADS_DIR, req.file.filename);
if (!checkMagicBytes(resubPath, req.file.mimetype)) {
try { fs.unlinkSync(resubPath); } catch {}
return res.status(400).json({ error: 'Содержимое файла не соответствует его расширению' });
}
// Delete old file
const oldPath = path.resolve(UPLOADS_DIR, sub.stored_name);
if (oldPath.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(oldPath); } catch {} }
const message = req.body.message?.trim() || null;
try {
db.prepare(`
UPDATE submissions SET original_name=?, stored_name=?, mimetype=?, size=?, message=?,
status='resubmitted', submitted_at=datetime('now'), teacher_note=NULL
WHERE id=?
`).run(req.file.originalname, req.file.filename, req.file.mimetype, req.file.size, message, sub.id);
} catch (err) {
const fp = path.resolve(UPLOADS_DIR, req.file.filename);
if (fp.startsWith(UPLOADS_DIR + path.sep)) { try { fs.unlinkSync(fp); } catch {} }
throw err;
}
// Notify teacher
try {
const cls = db.prepare('SELECT teacher_id, name FROM classes WHERE id = ?').get(sub.class_id);
if (cls?.teacher_id) {
const assignTitle = sub.assignment_id
? db.prepare('SELECT title FROM assignments WHERE id = ?').get(sub.assignment_id)?.title
: null;
const studentName = req.user.name || req.user.email;
const msg = assignTitle
? `«${studentName}» повторно сдал работу «${assignTitle}»`
: `«${studentName}» повторно сдал работу`;
pushNotif(cls.teacher_id, 'submission', msg, '/homework');
}
} catch (e) { console.error('[submissions] notify teacher resubmit:', e.message); }
res.json({ ok: true });
}
/* ── GET /api/submissions/log?class_id=X (admin only) ─────────────────── */
function getSubmissionLog(req, res) {
const { class_id } = req.query;
let sql = `
SELECT sl.*, u.name AS deleted_by_name,
c.name AS class_name, a.title AS assignment_title
FROM submission_log sl
LEFT JOIN users u ON u.id = sl.deleted_by
LEFT JOIN classes c ON c.id = sl.class_id
LEFT JOIN assignments a ON a.id = sl.assignment_id
`;
const args = [];
if (class_id) {
sql += ' WHERE sl.class_id = ?';
args.push(Number(class_id));
}
sql += ' ORDER BY sl.deleted_at DESC LIMIT 200';
res.json(db.prepare(sql).all(...args));
}
/* ── DELETE /api/submissions/log (admin only) ─────────────────────────── */
function clearSubmissionLog(req, res) {
db.prepare('DELETE FROM submission_log').run();
res.json({ ok: true });
}
module.exports = { submit, getMySubmissions, getClassSubmissions, reviewSubmission, downloadSubmission, deleteSubmission, resubmit, getSubmissionLog, clearSubmissionLog };
@@ -0,0 +1,317 @@
const db = require('../db/db');
/* ══════════════════════════════════════════════════════════════════════
COURSE TEMPLATES
══════════════════════════════════════════════════════════════════════ */
/* ── GET /api/templates/courses ──────────────────────────────────────── */
function listCourseTemplates(req, res) {
const uid = req.user.id;
const { my, subject } = req.query;
let where;
const args = [];
if (my) {
where = 'WHERE ct.created_by = ?';
args.push(uid);
} else if (subject) {
where = 'WHERE (ct.is_public = 1 OR ct.created_by = ?) AND ct.subject_slug = ?';
args.push(uid, subject);
} else {
where = 'WHERE ct.is_public = 1 OR ct.created_by = ?';
args.push(uid);
}
const rows = db.prepare(`
SELECT ct.*, u.name AS creator_name
FROM course_templates ct
LEFT JOIN users u ON ct.created_by = u.id
${where}
ORDER BY ct.created_at DESC
`).all(...args);
res.json(rows.map(r => ({
id: r.id,
title: r.title,
description: r.description || '',
category: r.category,
subjectSlug: r.subject_slug,
structure: safeJSON(r.structure, {}),
isPublic: r.is_public === 1,
createdBy: r.created_by,
creatorName: r.creator_name || '',
createdAt: r.created_at,
})));
}
/* ── POST /api/templates/courses ─────────────────────────────────────── */
function saveCourseTemplate(req, res) {
const uid = req.user.id;
const { title, description, category, subject_slug, courseId } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
let structure = {};
if (courseId) {
// Snapshot course structure
const course = db.prepare('SELECT * FROM courses WHERE id = ?').get(courseId);
if (!course) return res.status(404).json({ error: 'Course not found' });
const sections = db.prepare(
'SELECT * FROM course_sections WHERE course_id = ? ORDER BY order_index, id'
).all(courseId);
const lessons = db.prepare(
'SELECT * FROM lessons WHERE course_id = ? ORDER BY order_index, id'
).all(courseId);
const sectionArr = sections.map(s => {
const sectionLessons = lessons.filter(l => l.section_id === s.id);
return {
title: s.title,
lessons: sectionLessons.map(l => ({
title: l.title,
blocks: db.prepare(
'SELECT type, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
).all(l.id).map(b => ({ type: b.type, data: safeJSON(b.data, {}) })),
})),
};
});
// Lessons without a section
const unsectioned = lessons.filter(l => !l.section_id);
if (unsectioned.length) {
sectionArr.unshift({
title: null,
lessons: unsectioned.map(l => ({
title: l.title,
blocks: db.prepare(
'SELECT type, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
).all(l.id).map(b => ({ type: b.type, data: safeJSON(b.data, {}) })),
})),
});
}
structure = { sections: sectionArr };
}
const r = db.prepare(`
INSERT INTO course_templates (title, description, category, subject_slug, structure, is_public, created_by)
VALUES (?, ?, ?, ?, ?, 1, ?)
`).run(
title.trim(),
description || null,
category || 'general',
subject_slug || null,
JSON.stringify(structure),
uid
);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── POST /api/templates/courses/:id/create ──────────────────────────── */
function createFromCourseTemplate(req, res) {
const tpl = db.prepare('SELECT * FROM course_templates WHERE id = ?').get(req.params.id);
if (!tpl) return res.status(404).json({ error: 'Template not found' });
const { title, subjectSlug } = req.body;
const structure = safeJSON(tpl.structure, {});
const sections = structure.sections || [];
let newCourseId;
db.transaction(() => {
const cr = db.prepare(`
INSERT INTO courses (subject_slug, title, description, cover_emoji, order_index, is_published, created_by)
VALUES (?, ?, ?, '', 0, 0, ?)
`).run(
subjectSlug || tpl.subject_slug || 'other',
title || tpl.title,
tpl.description || null,
req.user.id
);
newCourseId = cr.lastInsertRowid;
let lessonOrder = 0;
for (const sec of sections) {
let sectionId = null;
if (sec.title) {
const sr = db.prepare(
'INSERT INTO course_sections (course_id, title, order_index) VALUES (?, ?, ?)'
).run(newCourseId, sec.title, lessonOrder);
sectionId = sr.lastInsertRowid;
}
for (const lesson of (sec.lessons || [])) {
const lr = db.prepare(
'INSERT INTO lessons (course_id, title, order_index, section_id) VALUES (?, ?, ?, ?)'
).run(newCourseId, lesson.title, lessonOrder++, sectionId);
const newLid = lr.lastInsertRowid;
(lesson.blocks || []).forEach((b, i) => {
db.prepare(
'INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)'
).run(newLid, b.type, i, JSON.stringify(b.data || {}));
});
}
}
})();
res.status(201).json({ id: newCourseId });
}
/* ── DELETE /api/templates/courses/:id ────────────────────────────────── */
function deleteCourseTemplate(req, res) {
const tpl = db.prepare('SELECT * FROM course_templates WHERE id = ?').get(req.params.id);
if (!tpl) return res.status(404).json({ error: 'Template not found' });
if (tpl.created_by !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM course_templates WHERE id = ?').run(tpl.id);
res.json({ ok: true });
}
/* ══════════════════════════════════════════════════════════════════════
LESSON TEMPLATES
══════════════════════════════════════════════════════════════════════ */
/* ── GET /api/templates/lessons ──────────────────────────────────────── */
function listLessonTemplates(req, res) {
const uid = req.user.id;
const { my, category } = req.query;
let where;
const args = [];
if (my) {
where = 'WHERE lt.created_by = ?';
args.push(uid);
} else if (category) {
where = 'WHERE (lt.is_public = 1 OR lt.created_by = ?) AND lt.category = ?';
args.push(uid, category);
} else {
where = 'WHERE lt.is_public = 1 OR lt.created_by = ?';
args.push(uid);
}
const rows = db.prepare(`
SELECT lt.*, u.name AS creator_name
FROM lesson_templates lt
LEFT JOIN users u ON lt.created_by = u.id
${where}
ORDER BY lt.created_at DESC
`).all(...args);
res.json(rows.map(r => ({
id: r.id,
title: r.title,
category: r.category,
subjectSlug: r.subject_slug,
blocks: safeJSON(r.blocks, []),
isPublic: r.is_public === 1,
createdBy: r.created_by,
creatorName: r.creator_name || '',
createdAt: r.created_at,
})));
}
/* ── POST /api/templates/lessons ─────────────────────────────────────── */
function saveLessonTemplate(req, res) {
const uid = req.user.id;
const { title, category, subject_slug, lessonId } = req.body;
if (!title) return res.status(400).json({ error: 'title required' });
let blocksJSON = '[]';
if (lessonId) {
const lesson = db.prepare('SELECT id FROM lessons WHERE id = ?').get(lessonId);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const rawBlocks = db.prepare(
'SELECT type, data FROM lesson_blocks WHERE lesson_id = ? ORDER BY order_index, id'
).all(lesson.id);
blocksJSON = JSON.stringify(rawBlocks.map(b => ({
type: b.type,
data: safeJSON(b.data, {}),
})));
}
const r = db.prepare(`
INSERT INTO lesson_templates (title, category, subject_slug, blocks, is_public, created_by)
VALUES (?, ?, ?, ?, 1, ?)
`).run(
title.trim(),
category || 'general',
subject_slug || null,
blocksJSON,
uid
);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── POST /api/templates/lessons/:id/create ──────────────────────────── */
function createFromLessonTemplate(req, res) {
const tpl = db.prepare('SELECT * FROM lesson_templates WHERE id = ?').get(req.params.id);
if (!tpl) return res.status(404).json({ error: 'Template not found' });
const { courseId, sectionId, title } = req.body;
if (!courseId) return res.status(400).json({ error: 'courseId required' });
const course = db.prepare('SELECT id FROM courses WHERE id = ?').get(courseId);
if (!course) return res.status(404).json({ error: 'Course not found' });
const tplBlocks = safeJSON(tpl.blocks, []);
let newLessonId;
db.transaction(() => {
// Get max order_index
const maxOrd = db.prepare(
'SELECT MAX(order_index) AS mx FROM lessons WHERE course_id = ?'
).get(courseId);
const lr = db.prepare(
'INSERT INTO lessons (course_id, title, order_index, section_id) VALUES (?, ?, ?, ?)'
).run(courseId, title || tpl.title, (maxOrd?.mx ?? -1) + 1, sectionId || null);
newLessonId = lr.lastInsertRowid;
tplBlocks.forEach((b, i) => {
db.prepare(
'INSERT INTO lesson_blocks (lesson_id, type, order_index, data) VALUES (?, ?, ?, ?)'
).run(newLessonId, b.type, i, JSON.stringify(b.data || {}));
});
})();
res.status(201).json({ id: newLessonId });
}
/* ── DELETE /api/templates/lessons/:id ────────────────────────────────── */
function deleteLessonTemplate(req, res) {
const tpl = db.prepare('SELECT * FROM lesson_templates WHERE id = ?').get(req.params.id);
if (!tpl) return res.status(404).json({ error: 'Template not found' });
if (tpl.created_by !== req.user.id && req.user.role !== 'admin')
return res.status(403).json({ error: 'Forbidden' });
db.prepare('DELETE FROM lesson_templates WHERE id = ?').run(tpl.id);
res.json({ ok: true });
}
/* ── helpers ─────────────────────────────────────────────────────────── */
function safeJSON(str, fallback) {
try { return JSON.parse(str); } catch { return fallback; }
}
module.exports = {
listCourseTemplates,
saveCourseTemplate,
createFromCourseTemplate,
deleteCourseTemplate,
listLessonTemplates,
saveLessonTemplate,
createFromLessonTemplate,
deleteLessonTemplate,
};
+134
View File
@@ -0,0 +1,134 @@
const db = require('../db/db');
/* ── GET /api/tests ─────────────────────────────────────────────────────── */
function list(req, res) {
const { subject } = req.query;
const { role, id: uid } = req.user;
const args = [];
let where = '1=1';
if (subject) { where += ' AND t.subject_slug = ?'; args.push(subject); }
if (role !== 'admin') { where += ' AND t.created_by = ?'; args.push(uid); }
const rows = db.prepare(`
SELECT t.id, t.title, t.subject_slug, t.description, t.created_at,
u.name AS creator_name,
COUNT(tq.question_id) AS question_count
FROM tests t
JOIN users u ON u.id = t.created_by
LEFT JOIN test_questions tq ON tq.test_id = t.id
WHERE ${where}
GROUP BY t.id ORDER BY t.created_at DESC
`).all(...args);
res.json(rows);
}
/* ── POST /api/tests ─────────────────────────────────────────────────────── */
function create(req, res) {
const { title, subject_slug, description, show_answers = 1, time_limit } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'title required' });
if (!subject_slug) return res.status(400).json({ error: 'subject_slug required' });
const tl = time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null;
const r = db.prepare(
'INSERT INTO tests (title, subject_slug, description, show_answers, time_limit, created_by) VALUES (?, ?, ?, ?, ?, ?)'
).run(title.trim(), subject_slug, description?.trim() || null, show_answers ? 1 : 0, tl, req.user.id);
res.status(201).json({ id: r.lastInsertRowid });
}
/* ── GET /api/tests/:id ──────────────────────────────────────────────────── */
function getOne(req, res) {
const t = db.prepare(`
SELECT t.*, u.name AS creator_name
FROM tests t JOIN users u ON u.id = t.created_by WHERE t.id = ?
`).get(req.params.id);
if (!t) return res.status(404).json({ error: 'Not found' });
const questions = db.prepare(`
SELECT q.id, q.text, q.type, q.difficulty, q.explanation,
tp.name AS topic, s.name AS subject_name,
(SELECT json_group_array(json_object('id',o.id,'text',o.text,'is_correct',o.is_correct,'order_index',o.order_index,'match_pair',o.match_pair) ORDER BY o.order_index)
FROM options o WHERE o.question_id = q.id) AS options_json,
tq.order_index
FROM test_questions tq
JOIN questions q ON q.id = tq.question_id
LEFT JOIN topics tp ON tp.id = q.topic_id
LEFT JOIN subjects s ON s.id = q.subject_id
WHERE tq.test_id = ? ORDER BY tq.order_index
`).all(req.params.id).map(r => ({
...r,
options: JSON.parse(r.options_json || '[]'),
options_json: undefined,
}));
res.json({ ...t, questions });
}
/* ── PUT /api/tests/:id ──────────────────────────────────────────────────── */
function update(req, res) {
const { title, subject_slug, description, show_answers, time_limit } = req.body;
const t = req.resource; // ownership verified by requireOwnership middleware
const tl = time_limit !== undefined ? (time_limit ? Math.max(1, Math.min(600, Number(time_limit))) : null) : undefined;
db.prepare('UPDATE tests SET title = ?, subject_slug = ?, description = ?, show_answers = ?, time_limit = ? WHERE id = ?')
.run(title?.trim(), subject_slug, description?.trim() || null, show_answers === undefined ? 1 : (show_answers ? 1 : 0),
tl !== undefined ? tl : t.time_limit,
t.id);
res.json({ ok: true });
}
/* ── DELETE /api/tests/:id ───────────────────────────────────────────────── */
function remove(req, res) {
db.prepare('DELETE FROM tests WHERE id = ?').run(req.resource.id);
res.json({ ok: true });
}
/* ── POST /api/tests/:id/questions ──────────────────────────────────────── */
function addQuestions(req, res) {
const { question_ids } = req.body;
if (!Array.isArray(question_ids) || !question_ids.length)
return res.status(400).json({ error: 'question_ids[] required' });
const testId = req.resource.id; // ownership verified by requireOwnership middleware
const { mx } = db.prepare(
'SELECT COALESCE(MAX(order_index), -1) AS mx FROM test_questions WHERE test_id = ?'
).get(testId);
let idx = mx + 1;
const ins = db.prepare(
'INSERT OR IGNORE INTO test_questions (test_id, question_id, order_index) VALUES (?, ?, ?)'
);
try {
db.transaction(() => { question_ids.forEach(qid => ins.run(testId, qid, idx++)); })();
} catch (e) {
return res.status(500).json({ error: e.message });
}
res.json({ ok: true });
}
/* ── DELETE /api/tests/:id/questions/:qid ───────────────────────────────── */
function removeQuestion(req, res) {
db.prepare('DELETE FROM test_questions WHERE test_id = ? AND question_id = ?')
.run(req.resource.id, req.params.qid);
res.json({ ok: true });
}
/* ── PATCH /api/tests/:id/questions/reorder { ids: [qid, qid, ...] } ──── */
function reorderQuestions(req, res) {
const { ids } = req.body;
if (!Array.isArray(ids) || !ids.length)
return res.status(400).json({ error: 'ids[] required' });
const testId = req.resource.id;
const upd = db.prepare(
'UPDATE test_questions SET order_index = ? WHERE test_id = ? AND question_id = ?'
);
try {
db.transaction(() => {
ids.forEach((qid, i) => upd.run(i, testId, qid));
})();
} catch (e) {
return res.status(500).json({ error: e.message });
}
res.json({ ok: true });
}
module.exports = { list, create, getOne, update, remove, addQuestions, removeQuestion, reorderQuestions };