LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend. Features: real-time collaborative whiteboard (SSE), multi-page support, LaTeX formulas, shapes/connectors, coordinate systems, number lines, compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with rotation & resize controls, minimap navigation overlay, auto-measurements, multi-page thumbnails sidebar, PNG export, page templates. Student/teacher workflows: classes, assignments, library, dashboard. Mobile responsive. SQLite (better-sqlite3). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user