const db = require('../db/db'); const crypto = require('crypto'); const { onClassJoined } = require('./gamificationController'); const { pushNotif } = require('../utils/notifications'); const { stripTags } = require('../utils/sanitize'); const { purgeAccessFor } = require('../services/contentAccess'); 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); // Правила доступа к контенту для этого класса (нет FK — единая чистка): purgeAccessFor('class', 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: students in their classes + personal students (teacher_students) const rows = db.prepare(` SELECT id, name, email FROM ( 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') UNION SELECT u.id, u.name, u.email FROM users u JOIN teacher_students ts ON ts.student_id = u.id WHERE ts.teacher_id = ? AND u.role IN ('student','free_student') ) ORDER BY name `).all(req.user.id, 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 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 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, };