From 977e46e75bfd2817af6a339c78fc4ca9cbb1b700 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 6 May 2026 17:30:37 +0300 Subject: [PATCH] refactor: split classroomController.js into 7 domain files (phase 2 of 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1618-line monolith split into: classroom/_shared.js — GUEST_EVENTS, emitToSession, hasAccess, canDraw classroom/sessions.js — lifecycle + guest tokens (12 functions) classroom/strokes.js — CRUD + cursor + preview (7 functions) classroom/pages.js — page CRUD + theme (8 functions) classroom/chat.js — messages, reactions, attachments, export (7 functions) classroom/permissions.js — draw, hand, mute, screen, attendance (11 functions) classroom/sim.js — simulation relay (5 functions) classroom/admin.js — history, notes, templates, admin views (14 functions) classroomController.js is now a 9-line re-export facade. routes/classroom.js unchanged. All 65 exports verified. Tests pass. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/controllers/classroom/_shared.js | 35 + backend/src/controllers/classroom/admin.js | 318 ++++ backend/src/controllers/classroom/chat.js | 158 ++ backend/src/controllers/classroom/pages.js | 164 ++ .../src/controllers/classroom/permissions.js | 149 ++ backend/src/controllers/classroom/sessions.js | 218 +++ backend/src/controllers/classroom/sim.js | 73 + backend/src/controllers/classroom/strokes.js | 132 ++ .../src/controllers/classroomController.js | 1626 +---------------- 9 files changed, 1257 insertions(+), 1616 deletions(-) create mode 100644 backend/src/controllers/classroom/_shared.js create mode 100644 backend/src/controllers/classroom/admin.js create mode 100644 backend/src/controllers/classroom/chat.js create mode 100644 backend/src/controllers/classroom/pages.js create mode 100644 backend/src/controllers/classroom/permissions.js create mode 100644 backend/src/controllers/classroom/sessions.js create mode 100644 backend/src/controllers/classroom/sim.js create mode 100644 backend/src/controllers/classroom/strokes.js diff --git a/backend/src/controllers/classroom/_shared.js b/backend/src/controllers/classroom/_shared.js new file mode 100644 index 0000000..e9c88ea --- /dev/null +++ b/backend/src/controllers/classroom/_shared.js @@ -0,0 +1,35 @@ +'use strict'; +const db = require('../../db/db'); + +const GUEST_EVENTS = new Set([ + 'classroom_strokes', 'classroom_stroke_preview', 'classroom_stroke_deleted', + 'classroom_stroke_updated', 'classroom_page_added', 'classroom_page_changed', + 'classroom_template_changed', 'classroom_page_cleared', 'classroom_page_renamed', + 'classroom_page_duplicated', 'classroom_page_deleted', 'classroom_ended', + 'classroom_board_theme', +]); + +function emitToSession(sessionId, data) { + require('../../ws-server').broadcastToSession(sessionId, data, GUEST_EVENTS.has(data.type)); +} + +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); + } +} + +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); +} + +module.exports = { GUEST_EVENTS, emitToSession, hasAccess, canDraw }; diff --git a/backend/src/controllers/classroom/admin.js b/backend/src/controllers/classroom/admin.js new file mode 100644 index 0000000..1c529af --- /dev/null +++ b/backend/src/controllers/classroom/admin.js @@ -0,0 +1,318 @@ +'use strict'; +const db = require('../../db/db'); +const { emitToSession, hasAccess } = require('./_shared'); + +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 }); +} + +function getClassHistory(req, res) { + const classId = Number(req.params.classId); + const page = Math.max(1, Number(req.query.page) || 1); + const limit = Math.min(50, Number(req.query.limit) || 20); + const search = (req.query.search || '').trim(); + const offset = (page - 1) * limit; + + const cls = db.prepare('SELECT * FROM classes WHERE id=?').get(classId); + if (!cls) return res.status(404).json({ error: 'Класс не найден' }); + if (req.user.role === 'teacher') { + if (cls.teacher_id !== req.user.id) return res.status(403).json({ error: 'Нет доступа' }); + } else if (req.user.role === 'student' || req.user.role === 'free_student') { + const member = db.prepare('SELECT 1 FROM class_members WHERE class_id=? AND user_id=?').get(classId, req.user.id); + if (!member) return res.status(403).json({ error: 'Нет доступа' }); + } + + const whereSearch = search ? `AND (s.title LIKE '%'||?||'%')` : ''; + const params = search ? [classId, search] : [classId]; + const total = db.prepare(`SELECT COUNT(*) AS n FROM classroom_sessions s WHERE s.class_id=? AND s.status='ended' ${whereSearch}`).get(...params).n; + const sessions = db.prepare(` + SELECT s.id, s.title, s.created_at, s.ended_at, s.teacher_id, + u.name AS teacher_name, + (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count, + (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count, + (COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count, + (SELECT COUNT(*) FROM classroom_strokes WHERE session_id=s.id) AS stroke_count + FROM classroom_sessions s JOIN users u ON u.id = s.teacher_id + WHERE s.class_id=? AND s.status='ended' ${whereSearch} + ORDER BY s.ended_at DESC LIMIT ? OFFSET ? + `).all(...params, limit, offset); + res.json({ sessions, total, page, pages: Math.ceil(total / limit) }); +} + +function getMyHistory(req, res) { + const page = Math.max(1, Number(req.query.page) || 1); + const limit = Math.min(50, Number(req.query.limit) || 20); + const offset = (page - 1) * limit; + let sessions, total; + + if (req.user.role === 'teacher' || req.user.role === 'admin') { + total = db.prepare(`SELECT COUNT(*) AS n FROM classroom_sessions WHERE teacher_id=? AND status='ended'`).get(req.user.id).n; + sessions = db.prepare(` + SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id, s.teacher_id, + u.name AS teacher_name, c.name AS class_name, + (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count, + (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count, + (COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count + FROM classroom_sessions s JOIN users u ON u.id = s.teacher_id + LEFT JOIN classes c ON c.id = s.class_id + WHERE s.teacher_id=? AND s.status='ended' ORDER BY s.ended_at DESC LIMIT ? OFFSET ? + `).all(req.user.id, limit, offset); + } else { + total = db.prepare(`SELECT COUNT(DISTINCT s.id) AS n FROM classroom_sessions s + JOIN classroom_attendance a ON a.session_id=s.id WHERE a.user_id=? AND s.status='ended'`).get(req.user.id).n; + sessions = db.prepare(` + SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id, + u.name AS teacher_name, c.name AS class_name, + (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count, + (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count, + (COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count + FROM classroom_sessions s JOIN users u ON u.id = s.teacher_id + LEFT JOIN classes c ON c.id = s.class_id + JOIN classroom_attendance a ON a.session_id=s.id + WHERE a.user_id=? AND s.status='ended' + GROUP BY s.id ORDER BY s.ended_at DESC LIMIT ? OFFSET ? + `).all(req.user.id, limit, offset); + } + res.json({ sessions, total, page, pages: Math.ceil(total / limit) }); +} + +function getSessionSummary(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 teacherName = db.prepare('SELECT name FROM users WHERE id=?').get(session.teacher_id)?.name || ''; + const className = session.class_id ? db.prepare('SELECT name FROM classes WHERE id=?').get(session.class_id)?.name || null : null; + const durationSec = session.ended_at ? Math.round((new Date(session.ended_at) - new Date(session.created_at)) / 1000) : null; + + const stats = { + duration_sec: durationSec, + participant_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_attendance WHERE session_id=?').get(sessionId).n, + message_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_chat WHERE session_id=?').get(sessionId).n, + page_count: db.prepare('SELECT COALESCE(MAX(page_num),1) AS n FROM classroom_pages WHERE session_id=?').get(sessionId).n, + stroke_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_strokes WHERE session_id=?').get(sessionId).n, + }; + + const attendance = db.prepare(` + SELECT a.user_id, u.name AS user_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).map(r => ({ ...r, duration_sec: (r.joined_at && r.left_at) ? Math.round((new Date(r.left_at) - new Date(r.joined_at)) / 1000) : null })); + + const pages = db.prepare(` + SELECT p.page_num, p.template, p.name, + (SELECT COUNT(*) FROM classroom_strokes WHERE session_id=p.session_id AND page_num=p.page_num) AS stroke_count + FROM classroom_pages p WHERE p.session_id=? ORDER BY p.page_num + `).all(sessionId); + if (!pages.length) pages.push({ page_num: 1, template: 'blank', name: null, stroke_count: stats.stroke_count }); + + res.json({ session: { ...session, teacher_name: teacherName, class_name: className }, stats, attendance, pages }); +} + +function getAllNotes(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 notes = db.prepare(` + SELECT n.content, n.updated_at, u.name AS user_name, u.id AS user_id + FROM classroom_notes n JOIN users u ON u.id = n.user_id + WHERE n.session_id=? AND n.content != '' ORDER BY u.name + `).all(sessionId); + res.json({ notes }); +} + +function deleteHistorySession(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.status !== 'ended') return res.status(400).json({ error: 'Можно удалять только завершённые сессии' }); + if (session.teacher_id !== req.user.id && req.user.role !== 'admin') + return res.status(403).json({ error: 'Нет доступа' }); + + db.transaction(() => { + db.prepare('DELETE FROM classroom_chat_reactions WHERE chat_id IN (SELECT id FROM classroom_chat WHERE session_id=?)').run(sessionId); + db.prepare('DELETE FROM classroom_chat WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_pages WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_attendance WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_notes WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_hands WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_invites WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_sessions WHERE id=?').run(sessionId); + })(); + res.json({ ok: true }); +} + +function adminGetActiveSessions(req, res) { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); + const sessions = db.prepare(` + SELECT s.id, s.title, s.created_at, s.class_id, s.teacher_id, + u.name AS teacher_name, c.name AS class_name, + (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id AND left_at IS NULL) AS online_count, + (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS total_joined, + (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count + FROM classroom_sessions s JOIN users u ON u.id = s.teacher_id + LEFT JOIN classes c ON c.id = s.class_id WHERE s.status='active' ORDER BY s.created_at DESC + `).all(); + res.json({ sessions }); +} + +function adminGetAllSessions(req, res) { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); + + const page = Math.max(1, Number(req.query.page) || 1); + const limit = Math.min(50, Number(req.query.limit) || 20); + const search = (req.query.search || '').trim(); + const teacherId = (req.query.teacher || '').trim(); + const classId = (req.query.class_id || '').trim(); + const dateFrom = (req.query.date_from || '').trim(); + const dateTo = (req.query.date_to || '').trim(); + const sort = req.query.sort || 'newest'; + const offset = (page - 1) * limit; + + let where = "s.status='ended'"; + const params = []; + if (search) { where += ` AND (s.title LIKE '%'||?||'%' OR u.name LIKE '%'||?||'%' OR c.name LIKE '%'||?||'%')`; params.push(search, search, search); } + if (teacherId) { where += ` AND s.teacher_id=?`; params.push(Number(teacherId)); } + if (classId) { where += ` AND s.class_id=?`; params.push(Number(classId)); } + if (dateFrom) { where += ` AND date(s.created_at)>=?`; params.push(dateFrom); } + if (dateTo) { where += ` AND date(s.created_at)<=?`; params.push(dateTo); } + + const orderBy = sort === 'oldest' ? 's.ended_at ASC' + : sort === 'longest' ? '(julianday(s.ended_at)-julianday(s.created_at)) DESC' + : sort === 'most_students' ? 'participant_count DESC' + : 's.ended_at DESC'; + + const total = db.prepare(`SELECT COUNT(*) AS n FROM classroom_sessions s + JOIN users u ON u.id = s.teacher_id LEFT JOIN classes c ON c.id = s.class_id WHERE ${where}`).get(...params).n; + + const sessions = db.prepare(` + SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id, s.teacher_id, + u.name AS teacher_name, c.name AS class_name, + (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count, + (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count, + COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1) AS page_count, + (SELECT COUNT(*) FROM classroom_strokes WHERE session_id=s.id) AS stroke_count + FROM classroom_sessions s JOIN users u ON u.id = s.teacher_id + LEFT JOIN classes c ON c.id = s.class_id + WHERE ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ? + `).all(...params, limit, offset); + + const agg = db.prepare(` + SELECT COUNT(*) AS total_sessions, COUNT(DISTINCT s.teacher_id) AS total_teachers, + COALESCE(SUM(CASE WHEN s.ended_at IS NOT NULL AND s.created_at IS NOT NULL + THEN CAST((julianday(s.ended_at) - julianday(s.created_at)) * 86400 AS INTEGER) + ELSE 0 END), 0) AS total_duration_sec + FROM classroom_sessions s JOIN users u ON u.id = s.teacher_id + LEFT JOIN classes c ON c.id = s.class_id WHERE ${where} + `).get(...params); + + const aggPart = db.prepare(` + SELECT COALESCE(SUM(sub.pc),0) AS total_participants, COALESCE(SUM(sub.mc),0) AS total_messages + FROM ( + SELECT s.id, + (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS pc, + (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS mc + FROM classroom_sessions s JOIN users u ON u.id = s.teacher_id + LEFT JOIN classes c ON c.id = s.class_id WHERE ${where} + ) sub + `).get(...params); + + res.json({ sessions, total, page, pages: Math.ceil(total / limit), agg: { ...agg, ...aggPart } }); +} + +function adminGetTeachersList(req, res) { + if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); + const teachers = db.prepare(` + SELECT DISTINCT u.id, u.name FROM classroom_sessions s + JOIN users u ON u.id = s.teacher_id WHERE s.status='ended' ORDER BY u.name + `).all(); + res.json({ teachers }); +} + +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=? AND teacher_id=?').get(template_id, req.user.id); + if (!tmpl && req.user.role !== 'admin') return res.status(404).json({ error: 'Шаблон не найден' }); + const tmplFallback = tmpl || db.prepare('SELECT * FROM classroom_templates WHERE id=?').get(template_id); + if (!tmplFallback) return res.status(404).json({ error: 'Шаблон не найден' }); + + const pagesData = JSON.parse(tmplFallback.pages_data || '[]'); + db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_pages WHERE session_id=?').run(sessionId); + 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)); + }); + }); + + emitToSession(sessionId, { type: 'classroom_template_loaded', sessionId, pages: pagesData.length }); + res.json({ ok: true, pages: pagesData.length }); +} + +module.exports = { + getNotes, saveNotes, getAllNotes, + getClassHistory, getMyHistory, getSessionSummary, deleteHistorySession, + adminGetActiveSessions, adminGetAllSessions, adminGetTeachersList, + getTemplates, saveTemplate, deleteTemplate, loadTemplate, +}; diff --git a/backend/src/controllers/classroom/chat.js b/backend/src/controllers/classroom/chat.js new file mode 100644 index 0000000..75267d1 --- /dev/null +++ b/backend/src/controllers/classroom/chat.js @@ -0,0 +1,158 @@ +'use strict'; +const db = require('../../db/db'); +const path = require('path'); +const fs = require('fs'); +const { emitToUser } = require('../../ws-server'); +const { emitToSession, hasAccess } = require('./_shared'); + +const CHAT_UPLOADS_DIR = path.join(__dirname, '../../../uploads/chat'); +if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true }); + +const ALLOWED_REACTIONS = ['like', 'heart', 'question', 'idea', 'wow']; + +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); + const safeUrl = attachment_url && typeof attachment_url === 'string' && attachment_url.startsWith('/uploads/') + ? attachment_url : null; + if (!text && !safeUrl) 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, safeUrl, safeUrl ? (attachment_type || null) : 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); +} + +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(); + + 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 }); +} + +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: 'Сообщение не найдено' }); + + 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 }); +} + +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 session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(msg.session_id); + if (!session || !hasAccess(session, req.user.id, req.user.role)) + return res.status(403).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 }); +} + +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 }); +} + +function exportChat(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 messages = db.prepare(` + SELECT c.created_at, u.name AS user_name, c.message, c.attachment_url + FROM classroom_chat c JOIN users u ON u.id = c.user_id + WHERE c.session_id=? ORDER BY c.id ASC + `).all(sessionId); + + const title = session.title || `Урок #${sessionId}`; + const date = session.created_at ? new Date(session.created_at).toLocaleDateString('ru-RU') : ''; + let text = `Чат урока: ${title}\nДата: ${date}\n${'─'.repeat(50)}\n\n`; + messages.forEach(m => { + const ts = m.created_at ? new Date(m.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) : ''; + text += `[${ts}] ${m.user_name}: ${m.message || ''}`; + if (m.attachment_url) text += ` [вложение]`; + text += '\n'; + }); + + const filename = `chat_${sessionId}_${date.replace(/\./g, '-')}.txt`; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.send(text); +} + +module.exports = { + CHAT_UPLOADS_DIR, ALLOWED_REACTIONS, + sendChat, getChat, pinMessage, reactToMessage, uploadChatAttachment, exportChat, +}; diff --git a/backend/src/controllers/classroom/pages.js b/backend/src/controllers/classroom/pages.js new file mode 100644 index 0000000..a38ff15 --- /dev/null +++ b/backend/src/controllers/classroom/pages.js @@ -0,0 +1,164 @@ +'use strict'; +const db = require('../../db/db'); +const { emitToSession, hasAccess } = require('./_shared'); + +function getPages(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 maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m; + const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m; + const total = Math.max(session.current_page, maxS, maxP, 1); + const rows = db.prepare('SELECT page_num, template, name FROM classroom_pages WHERE session_id=?').all(sessionId); + const map = {}; + rows.forEach(r => { map[r.page_num] = r; }); + const pages = []; + for (let i = 1; i <= total; i++) { + pages.push({ page_num: i, template: map[i]?.template || 'blank', name: map[i]?.name || null }); + } + res.json({ pages, currentPage: session.current_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 }); +} + +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) }); +} + +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 }); +} + +function updateBoardTheme(req, res) { + const sessionId = Number(req.params.id); + const VALID_THEMES = new Set(['chalkboard', 'blackboard', 'corkboard', 'whiteboard']); + const { theme } = req.body; + if (!theme || !VALID_THEMES.has(theme)) return res.status(400).json({ error: 'invalid theme' }); + + 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 board_theme=? WHERE id=?').run(theme, sessionId); + emitToSession(sessionId, { type: 'classroom_board_theme', sessionId, theme }); + res.json({ ok: true, theme }); +} + +function renamePage(req, res) { + const sessionId = Number(req.params.id); + const pageNum = Number(req.params.pageNum); + const { name } = 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('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)').run(sessionId, pageNum, 'blank'); + db.prepare('UPDATE classroom_pages SET name=? WHERE session_id=? AND page_num=?').run(name || null, sessionId, pageNum); + emitToSession(sessionId, { type: 'classroom_page_renamed', sessionId, pageNum, name: name || null }); + res.json({ ok: true, pageNum, name: name || null }); +} + +function duplicatePage(req, res) { + const sessionId = Number(req.params.id); + const srcPage = Number(req.params.pageNum); + + 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 srcRow = db.prepare('SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, srcPage); + const srcTpl = srcRow?.template || 'blank'; + const newName = srcRow?.name ? srcRow.name + ' (копия)' : null; + + const maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m; + const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m; + const newPage = Math.max(session.current_page, maxS, maxP) + 1; + + db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template, name) VALUES (?,?,?,?)').run(sessionId, newPage, srcTpl, newName); + + const strokes = db.prepare('SELECT tool, data FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq').all(sessionId, srcPage); + const ins = db.prepare('INSERT INTO classroom_strokes (session_id, page_num, tool, data, seq) VALUES (?,?,?,?,?)'); + db.transaction(() => { strokes.forEach((s, i) => ins.run(sessionId, newPage, s.tool, s.data, i + 1)); })(); + + db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newPage, sessionId); + emitToSession(sessionId, { type: 'classroom_page_duplicated', sessionId, srcPage, newPage, template: srcTpl, name: newName }); + res.json({ ok: true, newPage, template: srcTpl, name: newName }); +} + +function deletePage(req, res) { + const sessionId = Number(req.params.id); + const pageNum = Number(req.params.pageNum); + + 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 maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m; + const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m; + const total = Math.max(session.current_page, maxS, maxP, 1); + if (total <= 1) return res.status(400).json({ error: 'Нельзя удалить единственную страницу' }); + + db.transaction(() => { + db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, pageNum); + db.prepare('DELETE FROM classroom_pages WHERE session_id=? AND page_num=?').run(sessionId, pageNum); + db.prepare('UPDATE classroom_strokes SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum); + db.prepare('UPDATE classroom_pages SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum); + })(); + + let newCurrent = session.current_page; + if (newCurrent > pageNum) newCurrent--; + else if (newCurrent === pageNum) newCurrent = Math.max(1, pageNum - 1); + db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newCurrent, sessionId); + emitToSession(sessionId, { type: 'classroom_page_deleted', sessionId, pageNum, newCurrent }); + res.json({ ok: true, pageNum, newCurrent }); +} + +module.exports = { + getPages, addPage, changePage, updatePageTemplate, updateBoardTheme, + renamePage, duplicatePage, deletePage, +}; diff --git a/backend/src/controllers/classroom/permissions.js b/backend/src/controllers/classroom/permissions.js new file mode 100644 index 0000000..5f61617 --- /dev/null +++ b/backend/src/controllers/classroom/permissions.js @@ -0,0 +1,149 @@ +'use strict'; +const db = require('../../db/db'); +const { emitToUser, invalidateDrawCache } = require('../../ws-server'); +const { getOnlineUserIds } = require('../../sse'); +const { emitToSession, hasAccess } = require('./_shared'); + +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 }); +} + +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 }); +} + +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 }); +} + +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: 'Нет доступа' }); + + db.prepare('INSERT OR IGNORE INTO classroom_hands (session_id, user_id) VALUES (?,?)').run(sessionId, req.user.id); + emitToSession(sessionId, { type: 'classroom_hand_raised', sessionId, userId: req.user.id, userName: req.user.name }); + res.json({ ok: true }); +} + +function lowerHand(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: 'Нет доступа' }); + + db.prepare('DELETE FROM classroom_hands WHERE session_id=? AND user_id=?').run(sessionId, req.user.id); + emitToSession(sessionId, { type: 'classroom_hand_lowered', sessionId, userId: req.user.id }); + res.json({ ok: true }); +} + +function getHands(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 hands = db.prepare(` + SELECT h.user_id AS userId, u.name AS userName FROM classroom_hands h + JOIN users u ON u.id = h.user_id WHERE h.session_id=? + `).all(sessionId); + res.json({ hands }); +} + +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); + invalidateDrawCache(sessionId, targetId); + emitToUser(targetId, { type: 'classroom_draw_permitted', sessionId }); + res.json({ ok: true }); +} + +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); + invalidateDrawCache(sessionId, targetId); + emitToUser(targetId, { type: 'classroom_draw_revoked', sessionId }); + res.json({ ok: true }); +} + +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: 'Нет доступа' }); + + emitToUser(user_id, { type: 'classroom_muted', sessionId, by: req.user.id }); + res.json({ ok: true }); +} + +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 }); +} + +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 }); +} + +module.exports = { + getParticipants, getAttendance, getOnlineStudents, + raiseHand, lowerHand, getHands, + allowDraw, revokeDraw, mutePeer, + screenStart, screenStop, +}; diff --git a/backend/src/controllers/classroom/sessions.js b/backend/src/controllers/classroom/sessions.js new file mode 100644 index 0000000..c6c183a --- /dev/null +++ b/backend/src/controllers/classroom/sessions.js @@ -0,0 +1,218 @@ +'use strict'; +const db = require('../../db/db'); +const crypto = require('crypto'); +const { emitToUser, invalidateSession } = require('../../ws-server'); +const { emitToSession, hasAccess, canDraw } = require('./_shared'); + +function createSession(req, res) { + const { class_id, user_ids, title = '' } = req.body; + const teacher = req.user; + + const classroomEnabled = db.prepare("SELECT value FROM app_settings WHERE key='feature_classroom_enabled'").get(); + if (classroomEnabled?.value === '0') { + return res.status(403).json({ error: 'Модуль онлайн-уроков отключён администратором' }); + } + + if (!class_id && (!user_ids || !user_ids.length)) { + return res.status(400).json({ error: 'Укажите class_id или user_ids' }); + } + + if (class_id) { + 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: 'Нет доступа к классу' }); + + 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); + db.prepare('INSERT INTO classroom_pages (session_id, page_num) VALUES (?,1)').run(sessionId); + + 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); +} + +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 }); +} + +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); + db.prepare('DELETE FROM classroom_hands WHERE session_id=?').run(sessionId); + db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId); + emitToSession(sessionId, { type: 'classroom_ended', sessionId }); + res.json({ ok: true }); +} + +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 }); +} + +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 }); +} + +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); + + invalidateSession(sessionId); + emitToSession(sessionId, { type: 'classroom_user_joined', sessionId, userId: req.user.id, userName: req.user.name }); + + const drawAllowed = canDraw(sessionId, req.user.id, session) && session.teacher_id !== req.user.id; + if (drawAllowed) emitToUser(req.user.id, { type: 'classroom_draw_permitted', sessionId }); + res.json({ ok: true, canDraw: drawAllowed }); +} + +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); + + invalidateSession(sessionId); + 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 }); +} + +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: 'Нет доступа' }); + if (!hasAccess(session, target_user_id, 'student')) return res.status(403).json({ error: 'Цель не является участником сессии' }); + + emitToUser(target_user_id, { type: 'classroom_signal', sessionId, from: req.user.id, payload }); + res.json({ ok: true }); +} + +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 { + 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); + 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); + const wasJoined = attendance.some(a => a.user_id === userId); + res.json({ session: { ...session, pageCount, attendance }, wasJoined }); +} + +function generateGuestToken(req, res) { + const sessionId = Number(req.params.id); + const session = db.prepare('SELECT id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId); + if (!session) return res.status(404).json({ error: 'Not found' }); + if (session.teacher_id !== req.user.id && req.user.role !== 'admin') + return res.status(403).json({ error: 'Forbidden' }); + const token = crypto.randomBytes(24).toString('base64url'); + db.prepare('UPDATE classroom_sessions SET guest_token=? WHERE id=?').run(token, sessionId); + res.json({ token, url: `/guest-board.html?token=${token}` }); +} + +function revokeGuestToken(req, res) { + const sessionId = Number(req.params.id); + const session = db.prepare('SELECT id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId); + if (!session) return res.status(404).json({ error: 'Not found' }); + if (session.teacher_id !== req.user.id && req.user.role !== 'admin') + return res.status(403).json({ error: 'Forbidden' }); + db.prepare('UPDATE classroom_sessions SET guest_token=NULL WHERE id=?').run(sessionId); + res.json({ ok: true }); +} + +function getGuestToken(req, res) { + const sessionId = Number(req.params.id); + const session = db.prepare('SELECT id, teacher_id, guest_token FROM classroom_sessions WHERE id=?').get(sessionId); + if (!session) return res.status(404).json({ error: 'Not found' }); + if (session.teacher_id !== req.user.id && req.user.role !== 'admin') + return res.status(403).json({ error: 'Forbidden' }); + if (!session.guest_token) return res.json({ token: null, url: null }); + res.json({ token: session.guest_token, url: `/guest-board.html?token=${session.guest_token}` }); +} + +module.exports = { + createSession, getSession, endSession, getActiveSession, getMyActive, + joinSession, leaveSession, signal, getMySession, + generateGuestToken, revokeGuestToken, getGuestToken, +}; diff --git a/backend/src/controllers/classroom/sim.js b/backend/src/controllers/classroom/sim.js new file mode 100644 index 0000000..2079548 --- /dev/null +++ b/backend/src/controllers/classroom/sim.js @@ -0,0 +1,73 @@ +'use strict'; +const db = require('../../db/db'); +const { emitToSession } = require('./_shared'); + +function simOpen(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 { simId, title } = req.body; + if (!simId || typeof simId !== 'string' || !/^[a-z0-9_-]{1,40}$/.test(simId)) + return res.status(400).json({ error: 'Неверный simId' }); + + emitToSession(sessionId, { type: 'classroom_sim_open', sessionId, simId, title: (title || simId).slice(0, 80) }); + res.json({ ok: true }); +} + +function simState(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 { state } = req.body; + if (!state || typeof state !== 'object') return res.status(400).json({ error: 'Нет state' }); + const stateStr = JSON.stringify(state); + if (stateStr.length > 64_000) return res.status(413).json({ error: 'State слишком большой' }); + + emitToSession(sessionId, { type: 'classroom_sim_state', sessionId, state }); + res.json({ ok: true }); +} + +function simMode(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 { mode } = req.body; + if (mode !== 'demo' && mode !== 'free') return res.status(400).json({ error: 'mode must be demo|free' }); + + emitToSession(sessionId, { type: 'classroom_sim_mode', sessionId, mode }); + res.json({ ok: true }); +} + +function simAnnotate(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 { active } = req.body; + emitToSession(sessionId, { type: 'classroom_sim_annotate', sessionId, active: !!active }); + res.json({ ok: true }); +} + +function simClose(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_sim_close', sessionId }); + res.json({ ok: true }); +} + +module.exports = { simOpen, simState, simMode, simAnnotate, simClose }; diff --git a/backend/src/controllers/classroom/strokes.js b/backend/src/controllers/classroom/strokes.js new file mode 100644 index 0000000..b4b3b08 --- /dev/null +++ b/backend/src/controllers/classroom/strokes.js @@ -0,0 +1,132 @@ +'use strict'; +const db = require('../../db/db'); +const { emitToSession, hasAccess, canDraw } = require('./_shared'); + +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: 'Нет доступа' }); + + const insert = db.prepare('INSERT INTO classroom_strokes (session_id, page_num, user_id, tool, data, seq) VALUES (?,?,?,?,?,?)'); + const getMaxSeq = db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM classroom_strokes WHERE session_id=? AND page_num=?'); + + const saved = []; + db.transaction(() => { + let seq = getMaxSeq.get(sessionId, page_num).m; + 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 }); + } + })(); + + emitToSession(sessionId, { type: 'classroom_strokes', sessionId, pageNum: page_num, strokes: saved, userId: req.user.id }); + res.json({ strokes: saved }); +} + +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 STROKES_PAGE_LIMIT = 5000; + 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 LIMIT ?').all(sessionId, pageNum, sinceSeq, STROKES_PAGE_LIMIT) + : db.prepare('SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq LIMIT ?').all(sessionId, pageNum, STROKES_PAGE_LIMIT); + + const strokes = rows.map(r => ({ ...r, data: JSON.parse(r.data) })); + const hasMore = rows.length === STROKES_PAGE_LIMIT; + const pageRow = db.prepare('SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum); + res.json({ strokes, template: pageRow?.template || 'blank', name: pageRow?.name || null, hasMore }); +} + +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 }); +} + +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 }); +} + +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 }); +} + +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: 'Нет доступа' }); + + emitToSession(sessionId, { + 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, + }); + res.json({ ok: true }); +} + +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 }); +} + +module.exports = { postStrokes, getStrokes, updateStroke, deleteStroke, clearPage, previewStroke, broadcastCursor }; diff --git a/backend/src/controllers/classroomController.js b/backend/src/controllers/classroomController.js index dba18b4..ee9ca0d 100644 --- a/backend/src/controllers/classroomController.js +++ b/backend/src/controllers/classroomController.js @@ -1,1618 +1,12 @@ -const db = require('../db/db'); -const path = require('path'); -const fs = require('fs'); -const crypto = require('crypto'); -const { getOnlineUserIds } = require('../sse'); -const { emitToUser, invalidateSession, invalidateDrawCache } = require('../ws-server'); - -/* ── 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); -} - -/* Events forwarded to read-only guest viewers (whiteboard + lifecycle only) */ -const GUEST_EVENTS = new Set([ - 'classroom_strokes', 'classroom_stroke_preview', 'classroom_stroke_deleted', - 'classroom_stroke_updated', 'classroom_page_added', 'classroom_page_changed', - 'classroom_template_changed', 'classroom_page_cleared', 'classroom_page_renamed', - 'classroom_page_duplicated', 'classroom_page_deleted', 'classroom_ended', - 'classroom_board_theme', -]); - -/* ── Helper: broadcast to all session participants ─────────────────────── */ -/* Delivery: WS-first (ws-server.js) with SSE fallback for non-WS users. */ -function emitToSession(sessionId, data) { - // ws-server handles WS-first + SSE-fallback + guest forwarding internally - require('../ws-server').broadcastToSession(sessionId, data, GUEST_EVENTS.has(data.type)); -} - -/* ── 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; - - // Check if classroom module is enabled - const classroomEnabled = db.prepare("SELECT value FROM app_settings WHERE key='feature_classroom_enabled'").get(); - if (classroomEnabled?.value === '0') { - return res.status(403).json({ error: 'Модуль онлайн-уроков отключён администратором' }); - } - - 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); - - db.prepare('DELETE FROM classroom_hands WHERE session_id=?').run(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); - - invalidateSession(sessionId); - - 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) { - emitToUser(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); - - invalidateSession(sessionId); - - 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); - // Validate attachment_url: only allow local upload paths (prevent XSS via javascript:/data: URIs) - const safeUrl = attachment_url && typeof attachment_url === 'string' && attachment_url.startsWith('/uploads/') - ? attachment_url : null; - if (!text && !safeUrl) 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, safeUrl, safeUrl ? (attachment_type || null) : 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: 'Нет доступа' }); - - // Verify target is also a participant of this session - if (!hasAccess(session, target_user_id, 'student')) - return res.status(403).json({ error: 'Цель не является участником сессии' }); - - emitToUser(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 }); -} - -/* 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 }); -} - -/* PATCH /api/classroom/:id/board-theme — change board theme and broadcast to all */ -function updateBoardTheme(req, res) { - const sessionId = Number(req.params.id); - const VALID_THEMES = new Set(['chalkboard', 'blackboard', 'corkboard', 'whiteboard']); - const { theme } = req.body; - if (!theme || !VALID_THEMES.has(theme)) return res.status(400).json({ error: 'invalid theme' }); - 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 board_theme=? WHERE id=?').run(theme, sessionId); - emitToSession(sessionId, { type: 'classroom_board_theme', sessionId, theme }); - res.json({ ok: true, theme }); -} - -/* 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: 'Нет доступа' }); - - db.prepare('INSERT OR IGNORE INTO classroom_hands (session_id, user_id) VALUES (?,?)').run(sessionId, req.user.id); - - 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); - 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('DELETE FROM classroom_hands WHERE session_id=? AND user_id=?').run(sessionId, req.user.id); - 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 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 hands = db.prepare(` - SELECT h.user_id AS userId, u.name AS userName - FROM classroom_hands h - JOIN users u ON u.id = h.user_id - WHERE h.session_id=? - `).all(sessionId); - 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: 'Нет доступа' }); - - const insert = db.prepare( - 'INSERT INTO classroom_strokes (session_id, page_num, user_id, tool, data, seq) VALUES (?,?,?,?,?,?)' - ); - const getMaxSeq = db.prepare( - 'SELECT COALESCE(MAX(seq), 0) AS m FROM classroom_strokes WHERE session_id=? AND page_num=?' - ); - - const saved = []; - const insertMany = db.transaction(() => { - let seq = getMaxSeq.get(sessionId, page_num).m; - 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: 'Нет доступа' }); - - // LIMIT 5000 — guards against OOM on very long sessions; client paginates via since_seq - const STROKES_PAGE_LIMIT = 5000; - 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 LIMIT ?').all(sessionId, pageNum, sinceSeq, STROKES_PAGE_LIMIT) - : db.prepare('SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq LIMIT ?').all(sessionId, pageNum, STROKES_PAGE_LIMIT); - - const strokes = rows.map(r => ({ ...r, data: JSON.parse(r.data) })); - const hasMore = rows.length === STROKES_PAGE_LIMIT; - - const pageRow = db.prepare('SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum); - const template = pageRow?.template || 'blank'; - const name = pageRow?.name || null; - - res.json({ strokes, template, name, hasMore }); -} - -/* 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 }); -} - -/* GET /api/classroom/:id/pages — list all pages with names/templates */ -function getPages(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 maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m; - const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m; - const total = Math.max(session.current_page, maxS, maxP, 1); - - const rows = db.prepare('SELECT page_num, template, name FROM classroom_pages WHERE session_id=?').all(sessionId); - const map = {}; - rows.forEach(r => { map[r.page_num] = r; }); - - const pages = []; - for (let i = 1; i <= total; i++) { - pages.push({ page_num: i, template: map[i]?.template || 'blank', name: map[i]?.name || null }); - } - res.json({ pages, currentPage: session.current_page }); -} - -/* PATCH /api/classroom/:id/pages/:pageNum/name — rename page */ -function renamePage(req, res) { - const sessionId = Number(req.params.id); - const pageNum = Number(req.params.pageNum); - const { name } = 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('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)').run(sessionId, pageNum, 'blank'); - db.prepare('UPDATE classroom_pages SET name=? WHERE session_id=? AND page_num=?').run(name || null, sessionId, pageNum); - emitToSession(sessionId, { type: 'classroom_page_renamed', sessionId, pageNum, name: name || null }); - res.json({ ok: true, pageNum, name: name || null }); -} - -/* POST /api/classroom/:id/pages/:pageNum/duplicate — duplicate a page */ -function duplicatePage(req, res) { - const sessionId = Number(req.params.id); - const srcPage = Number(req.params.pageNum); - - 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 srcRow = db.prepare('SELECT template, name FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, srcPage); - const srcTpl = srcRow?.template || 'blank'; - const newName = srcRow?.name ? srcRow.name + ' (копия)' : null; - - const maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m; - const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m; - const newPage = Math.max(session.current_page, maxS, maxP) + 1; - - db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template, name) VALUES (?,?,?,?)').run(sessionId, newPage, srcTpl, newName); - - const strokes = db.prepare('SELECT tool, data FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq').all(sessionId, srcPage); - const ins = db.prepare('INSERT INTO classroom_strokes (session_id, page_num, tool, data, seq) VALUES (?,?,?,?,?)'); - db.transaction(() => { strokes.forEach((s, i) => ins.run(sessionId, newPage, s.tool, s.data, i + 1)); })(); - - db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newPage, sessionId); - emitToSession(sessionId, { type: 'classroom_page_duplicated', sessionId, srcPage, newPage, template: srcTpl, name: newName }); - res.json({ ok: true, newPage, template: srcTpl, name: newName }); -} - -/* DELETE /api/classroom/:id/pages/:pageNum — delete a page */ -function deletePage(req, res) { - const sessionId = Number(req.params.id); - const pageNum = Number(req.params.pageNum); - - 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 maxS = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_strokes WHERE session_id=?').get(sessionId).m; - const maxP = db.prepare('SELECT COALESCE(MAX(page_num),0) AS m FROM classroom_pages WHERE session_id=?').get(sessionId).m; - const total = Math.max(session.current_page, maxS, maxP, 1); - if (total <= 1) return res.status(400).json({ error: 'Нельзя удалить единственную страницу' }); - - db.transaction(() => { - db.prepare('DELETE FROM classroom_strokes WHERE session_id=? AND page_num=?').run(sessionId, pageNum); - db.prepare('DELETE FROM classroom_pages WHERE session_id=? AND page_num=?').run(sessionId, pageNum); - db.prepare('UPDATE classroom_strokes SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum); - db.prepare('UPDATE classroom_pages SET page_num=page_num-1 WHERE session_id=? AND page_num>?').run(sessionId, pageNum); - })(); - - let newCurrent = session.current_page; - if (newCurrent > pageNum) newCurrent--; - else if (newCurrent === pageNum) newCurrent = Math.max(1, pageNum - 1); - db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newCurrent, sessionId); - emitToSession(sessionId, { type: 'classroom_page_deleted', sessionId, pageNum, newCurrent }); - res.json({ ok: true, pageNum, newCurrent }); -} - -/* 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: 'Нет доступа' }); - - emitToUser(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); - invalidateDrawCache(sessionId, targetId); - - emitToUser(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); - invalidateDrawCache(sessionId, targetId); - - emitToUser(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 }); -} - -/* ── Simulation integration ──────────────────────────────────────────────── */ - -/* POST /api/classroom/:id/sim — teacher opens simulation for everyone */ -function simOpen(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 { simId, title } = req.body; - if (!simId || typeof simId !== 'string' || !/^[a-z0-9_-]{1,40}$/.test(simId)) - return res.status(400).json({ error: 'Неверный simId' }); - - emitToSession(sessionId, { type: 'classroom_sim_open', sessionId, simId, title: (title || simId).slice(0, 80) }); - res.json({ ok: true }); -} - -/* POST /api/classroom/:id/sim/state — teacher relays sim state to students */ -function simState(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 { state } = req.body; - if (!state || typeof state !== 'object') return res.status(400).json({ error: 'Нет state' }); - // Limit state size to prevent OOM on broadcast - const stateStr = JSON.stringify(state); - if (stateStr.length > 64_000) return res.status(413).json({ error: 'State слишком большой' }); - - emitToSession(sessionId, { type: 'classroom_sim_state', sessionId, state }); - res.json({ ok: true }); -} - -/* POST /api/classroom/:id/sim/mode — teacher sets sim mode (demo/free) */ -function simMode(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 { mode } = req.body; - if (mode !== 'demo' && mode !== 'free') return res.status(400).json({ error: 'mode must be demo|free' }); - - emitToSession(sessionId, { type: 'classroom_sim_mode', sessionId, mode }); - res.json({ ok: true }); -} - -/* POST /api/classroom/:id/sim/annotate — teacher toggles draw-over-sim mode */ -function simAnnotate(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 { active } = req.body; - emitToSession(sessionId, { type: 'classroom_sim_annotate', sessionId, active: !!active }); - res.json({ ok: true }); -} - -/* DELETE /api/classroom/:id/sim — teacher closes simulation */ -function simClose(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_sim_close', 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 session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(msg.session_id); - if (!session || !hasAccess(session, req.user.id, req.user.role)) - return res.status(403).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 history ─────────────────────────────────────────────────────── */ - -/* GET /api/classroom/class/:classId/history */ -function getClassHistory(req, res) { - const classId = Number(req.params.classId); - const page = Math.max(1, Number(req.query.page) || 1); - const limit = Math.min(50, Number(req.query.limit) || 20); - const search = (req.query.search || '').trim(); - const offset = (page - 1) * limit; - - // Access check: admin sees all; teacher must own the class; student must be member - const cls = db.prepare('SELECT * FROM classes WHERE id=?').get(classId); - if (!cls) return res.status(404).json({ error: 'Класс не найден' }); - if (req.user.role === 'teacher') { - if (cls.teacher_id !== req.user.id) return res.status(403).json({ error: 'Нет доступа' }); - } else if (req.user.role === 'student' || req.user.role === 'free_student') { - const member = db.prepare('SELECT 1 FROM class_members WHERE class_id=? AND user_id=?').get(classId, req.user.id); - if (!member) return res.status(403).json({ error: 'Нет доступа' }); - } - - const whereSearch = search ? `AND (s.title LIKE '%'||?||'%')` : ''; - const params = search ? [classId, search] : [classId]; - - const total = db.prepare(` - SELECT COUNT(*) AS n FROM classroom_sessions s - WHERE s.class_id=? AND s.status='ended' ${whereSearch} - `).get(...params).n; - - const sessions = db.prepare(` - SELECT s.id, s.title, s.created_at, s.ended_at, s.teacher_id, - u.name AS teacher_name, - (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count, - (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count, - (COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count, - (SELECT COUNT(*) FROM classroom_strokes WHERE session_id=s.id) AS stroke_count - FROM classroom_sessions s - JOIN users u ON u.id = s.teacher_id - WHERE s.class_id=? AND s.status='ended' ${whereSearch} - ORDER BY s.ended_at DESC - LIMIT ? OFFSET ? - `).all(...params, limit, offset); - - res.json({ sessions, total, page, pages: Math.ceil(total / limit) }); -} - -/* GET /api/classroom/my/history */ -function getMyHistory(req, res) { - const page = Math.max(1, Number(req.query.page) || 1); - const limit = Math.min(50, Number(req.query.limit) || 20); - const offset = (page - 1) * limit; - - let sessions, total; - if (req.user.role === 'teacher' || req.user.role === 'admin') { - total = db.prepare(`SELECT COUNT(*) AS n FROM classroom_sessions WHERE teacher_id=? AND status='ended'`).get(req.user.id).n; - sessions = db.prepare(` - SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id, s.teacher_id, - u.name AS teacher_name, - c.name AS class_name, - (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count, - (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count, - (COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count - FROM classroom_sessions s - JOIN users u ON u.id = s.teacher_id - LEFT JOIN classes c ON c.id = s.class_id - WHERE s.teacher_id=? AND s.status='ended' - ORDER BY s.ended_at DESC LIMIT ? OFFSET ? - `).all(req.user.id, limit, offset); - } else { - // student: sessions they have attendance records for - total = db.prepare(` - SELECT COUNT(DISTINCT s.id) AS n FROM classroom_sessions s - JOIN classroom_attendance a ON a.session_id=s.id - WHERE a.user_id=? AND s.status='ended' - `).get(req.user.id).n; - sessions = db.prepare(` - SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id, - u.name AS teacher_name, - c.name AS class_name, - (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count, - (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count, - (COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1)) AS page_count - FROM classroom_sessions s - JOIN users u ON u.id = s.teacher_id - LEFT JOIN classes c ON c.id = s.class_id - JOIN classroom_attendance a ON a.session_id=s.id - WHERE a.user_id=? AND s.status='ended' - GROUP BY s.id ORDER BY s.ended_at DESC LIMIT ? OFFSET ? - `).all(req.user.id, limit, offset); - } - - res.json({ sessions, total, page, pages: Math.ceil(total / limit) }); -} - -/* GET /api/classroom/:id/summary */ -function getSessionSummary(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 teacherName = db.prepare('SELECT name FROM users WHERE id=?').get(session.teacher_id)?.name || ''; - const className = session.class_id - ? db.prepare('SELECT name FROM classes WHERE id=?').get(session.class_id)?.name || null - : null; - - const durationSec = session.ended_at - ? Math.round((new Date(session.ended_at) - new Date(session.created_at)) / 1000) - : null; - - const stats = { - duration_sec: durationSec, - participant_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_attendance WHERE session_id=?').get(sessionId).n, - message_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_chat WHERE session_id=?').get(sessionId).n, - page_count: db.prepare('SELECT COALESCE(MAX(page_num),1) AS n FROM classroom_pages WHERE session_id=?').get(sessionId).n, - stroke_count: db.prepare('SELECT COUNT(*) AS n FROM classroom_strokes WHERE session_id=?').get(sessionId).n, - }; - - const attendance = db.prepare(` - SELECT a.user_id, u.name AS user_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).map(r => ({ - ...r, - duration_sec: (r.joined_at && r.left_at) - ? Math.round((new Date(r.left_at) - new Date(r.joined_at)) / 1000) - : null, - })); - - const pages = db.prepare(` - SELECT p.page_num, p.template, p.name, - (SELECT COUNT(*) FROM classroom_strokes WHERE session_id=p.session_id AND page_num=p.page_num) AS stroke_count - FROM classroom_pages p - WHERE p.session_id=? ORDER BY p.page_num - `).all(sessionId); - - // Ensure at least page 1 appears if no pages row exists - if (!pages.length) pages.push({ page_num: 1, template: 'blank', name: null, stroke_count: stats.stroke_count }); - - res.json({ - session: { ...session, teacher_name: teacherName, class_name: className }, - stats, - attendance, - pages, - }); -} - -/* GET /api/classroom/:id/chat/export — plaintext chat transcript (teacher/admin) */ -function exportChat(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 messages = db.prepare(` - SELECT c.created_at, u.name AS user_name, c.message, c.attachment_url - FROM classroom_chat c - JOIN users u ON u.id = c.user_id - WHERE c.session_id=? ORDER BY c.id ASC - `).all(sessionId); - - const title = session.title || `Урок #${sessionId}`; - const date = session.created_at ? new Date(session.created_at).toLocaleDateString('ru-RU') : ''; - let text = `Чат урока: ${title}\nДата: ${date}\n${'─'.repeat(50)}\n\n`; - - messages.forEach(m => { - const ts = m.created_at ? new Date(m.created_at).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) : ''; - text += `[${ts}] ${m.user_name}: ${m.message || ''}`; - if (m.attachment_url) text += ` [вложение]`; - text += '\n'; - }); - - const filename = `chat_${sessionId}_${date.replace(/\./g, '-')}.txt`; - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); - res.send(text); -} - -/* GET /api/classroom/:id/notes/all — all student notes (teacher/admin) */ -function getAllNotes(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 notes = db.prepare(` - SELECT n.content, n.updated_at, u.name AS user_name, u.id AS user_id - FROM classroom_notes n - JOIN users u ON u.id = n.user_id - WHERE n.session_id=? AND n.content != '' ORDER BY u.name - `).all(sessionId); - - res.json({ notes }); -} - -/* ── Lesson templates ───────────────────────────────────────────────────── */ -/* DELETE /api/classroom/:id/history — permanently delete an ended session record */ -function deleteHistorySession(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.status !== 'ended') return res.status(400).json({ error: 'Можно удалять только завершённые сессии' }); - if (session.teacher_id !== req.user.id && req.user.role !== 'admin') - return res.status(403).json({ error: 'Нет доступа' }); - - const deleteAll = db.transaction(() => { - db.prepare('DELETE FROM classroom_chat_reactions WHERE chat_id IN (SELECT id FROM classroom_chat WHERE session_id=?)').run(sessionId); - db.prepare('DELETE FROM classroom_chat WHERE session_id=?').run(sessionId); - db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId); - db.prepare('DELETE FROM classroom_pages WHERE session_id=?').run(sessionId); - db.prepare('DELETE FROM classroom_attendance WHERE session_id=?').run(sessionId); - db.prepare('DELETE FROM classroom_notes WHERE session_id=?').run(sessionId); - db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId); - db.prepare('DELETE FROM classroom_hands WHERE session_id=?').run(sessionId); - db.prepare('DELETE FROM classroom_invites WHERE session_id=?').run(sessionId); - db.prepare('DELETE FROM classroom_sessions WHERE id=?').run(sessionId); - }); - deleteAll(); - - res.json({ ok: true }); -} - -/* GET /api/classroom/admin/active — all currently active sessions (admin only) */ -function adminGetActiveSessions(req, res) { - if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); - - const sessions = db.prepare(` - SELECT s.id, s.title, s.created_at, s.class_id, s.teacher_id, - u.name AS teacher_name, - c.name AS class_name, - (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id AND left_at IS NULL) AS online_count, - (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS total_joined, - (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count - FROM classroom_sessions s - JOIN users u ON u.id = s.teacher_id - LEFT JOIN classes c ON c.id = s.class_id - WHERE s.status='active' - ORDER BY s.created_at DESC - `).all(); - - res.json({ sessions }); -} - -/* GET /api/classroom/admin/sessions — all sessions paginated (admin only) */ -function adminGetAllSessions(req, res) { - if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); - - const page = Math.max(1, Number(req.query.page) || 1); - const limit = Math.min(50, Number(req.query.limit) || 20); - const search = (req.query.search || '').trim(); - const teacherId = (req.query.teacher || '').trim(); - const classId = (req.query.class_id || '').trim(); - const dateFrom = (req.query.date_from || '').trim(); - const dateTo = (req.query.date_to || '').trim(); - const sort = req.query.sort || 'newest'; - const offset = (page - 1) * limit; - - let where = "s.status='ended'"; - const params = []; - if (search) { where += ` AND (s.title LIKE '%'||?||'%' OR u.name LIKE '%'||?||'%' OR c.name LIKE '%'||?||'%')`; params.push(search, search, search); } - if (teacherId) { where += ` AND s.teacher_id=?`; params.push(Number(teacherId)); } - if (classId) { where += ` AND s.class_id=?`; params.push(Number(classId)); } - if (dateFrom) { where += ` AND date(s.created_at)>=?`; params.push(dateFrom); } - if (dateTo) { where += ` AND date(s.created_at)<=?`; params.push(dateTo); } - - const orderBy = sort === 'oldest' ? 's.ended_at ASC' - : sort === 'longest' ? '(julianday(s.ended_at)-julianday(s.created_at)) DESC' - : sort === 'most_students' ? 'participant_count DESC' - : 's.ended_at DESC'; - - const total = db.prepare(` - SELECT COUNT(*) AS n FROM classroom_sessions s - JOIN users u ON u.id = s.teacher_id - LEFT JOIN classes c ON c.id = s.class_id - WHERE ${where} - `).get(...params).n; - - const sessions = db.prepare(` - SELECT s.id, s.title, s.created_at, s.ended_at, s.class_id, s.teacher_id, - u.name AS teacher_name, - c.name AS class_name, - (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS participant_count, - (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS message_count, - COALESCE((SELECT MAX(page_num) FROM classroom_pages WHERE session_id=s.id),1) AS page_count, - (SELECT COUNT(*) FROM classroom_strokes WHERE session_id=s.id) AS stroke_count - FROM classroom_sessions s - JOIN users u ON u.id = s.teacher_id - LEFT JOIN classes c ON c.id = s.class_id - WHERE ${where} - ORDER BY ${orderBy} - LIMIT ? OFFSET ? - `).all(...params, limit, offset); - - // Server-side aggregates (filtered) - const agg = db.prepare(` - SELECT COUNT(*) AS total_sessions, - COUNT(DISTINCT s.teacher_id) AS total_teachers, - COALESCE(SUM(CASE WHEN s.ended_at IS NOT NULL AND s.created_at IS NOT NULL - THEN CAST((julianday(s.ended_at) - julianday(s.created_at)) * 86400 AS INTEGER) - ELSE 0 END), 0) AS total_duration_sec - FROM classroom_sessions s - JOIN users u ON u.id = s.teacher_id - LEFT JOIN classes c ON c.id = s.class_id - WHERE ${where} - `).get(...params); - - const aggPart = db.prepare(` - SELECT COALESCE(SUM(sub.pc),0) AS total_participants, - COALESCE(SUM(sub.mc),0) AS total_messages - FROM ( - SELECT s.id, - (SELECT COUNT(*) FROM classroom_attendance WHERE session_id=s.id) AS pc, - (SELECT COUNT(*) FROM classroom_chat WHERE session_id=s.id) AS mc - FROM classroom_sessions s - JOIN users u ON u.id = s.teacher_id - LEFT JOIN classes c ON c.id = s.class_id - WHERE ${where} - ) sub - `).get(...params); - - res.json({ sessions, total, page, pages: Math.ceil(total / limit), - agg: { ...agg, ...aggPart } }); -} - -/* GET /api/classroom/admin/teachers-list — teachers who have sessions (admin only) */ -function adminGetTeachersList(req, res) { - if (req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); - const teachers = db.prepare(` - SELECT DISTINCT u.id, u.name - FROM classroom_sessions s - JOIN users u ON u.id = s.teacher_id - WHERE s.status='ended' - ORDER BY u.name - `).all(); - res.json({ teachers }); -} - -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=? AND teacher_id=?').get(template_id, req.user.id); - if (!tmpl && req.user.role !== 'admin') return res.status(404).json({ error: 'Шаблон не найден' }); - const tmplFallback = tmpl || db.prepare('SELECT * FROM classroom_templates WHERE id=?').get(template_id); - if (!tmplFallback) return res.status(404).json({ error: 'Шаблон не найден' }); - - const pagesData = JSON.parse(tmplFallback.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 }); -} - -/* ── Guest token: generate / revoke ───────────────────────────────────── */ -function generateGuestToken(req, res) { - const sessionId = Number(req.params.id); - const session = db.prepare('SELECT id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId); - if (!session) return res.status(404).json({ error: 'Not found' }); - if (session.teacher_id !== req.user.id && req.user.role !== 'admin') - return res.status(403).json({ error: 'Forbidden' }); - - const token = crypto.randomBytes(24).toString('base64url'); - db.prepare('UPDATE classroom_sessions SET guest_token=? WHERE id=?').run(token, sessionId); - res.json({ token, url: `/guest-board.html?token=${token}` }); -} - -function revokeGuestToken(req, res) { - const sessionId = Number(req.params.id); - const session = db.prepare('SELECT id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId); - if (!session) return res.status(404).json({ error: 'Not found' }); - if (session.teacher_id !== req.user.id && req.user.role !== 'admin') - return res.status(403).json({ error: 'Forbidden' }); - - db.prepare('UPDATE classroom_sessions SET guest_token=NULL WHERE id=?').run(sessionId); - res.json({ ok: true }); -} - -function getGuestToken(req, res) { - const sessionId = Number(req.params.id); - const session = db.prepare('SELECT id, teacher_id, guest_token FROM classroom_sessions WHERE id=?').get(sessionId); - if (!session) return res.status(404).json({ error: 'Not found' }); - if (session.teacher_id !== req.user.id && req.user.role !== 'admin') - return res.status(403).json({ error: 'Forbidden' }); - - if (!session.guest_token) return res.json({ token: null, url: null }); - res.json({ token: session.guest_token, url: `/guest-board.html?token=${session.guest_token}` }); -} - +'use strict'; +// Re-export facade — routes/classroom.js imports from this path unchanged. +// Implementation split into domain files under ./classroom/ module.exports = { - createSession, - getSession, - endSession, - getActiveSession, - getMyActive, - joinSession, - leaveSession, - sendChat, - getChat, - getAttendance, - getParticipants, - signal, - getOnlineStudents, - getMySession, - postStrokes, - getStrokes, - deleteStroke, - updateStroke, - addPage, - changePage, - updatePageTemplate, - updateBoardTheme, - getPages, - renamePage, - duplicatePage, - deletePage, - raiseHand, - lowerHand, - getHands, - mutePeer, - screenStart, - screenStop, - simOpen, - simClose, - simState, - simMode, - simAnnotate, - clearPage, - previewStroke, - broadcastCursor, - pinMessage, - allowDraw, - revokeDraw, - uploadChatAttachment, - reactToMessage, - getNotes, - saveNotes, - getTemplates, - saveTemplate, - deleteTemplate, - loadTemplate, - getClassHistory, - getMyHistory, - getSessionSummary, - exportChat, - getAllNotes, - deleteHistorySession, - adminGetActiveSessions, - adminGetAllSessions, - adminGetTeachersList, - generateGuestToken, - revokeGuestToken, - getGuestToken, + ...require('./classroom/sessions'), + ...require('./classroom/strokes'), + ...require('./classroom/pages'), + ...require('./classroom/chat'), + ...require('./classroom/permissions'), + ...require('./classroom/sim'), + ...require('./classroom/admin'), };