const db = require('../db/db'); const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const { emit, emitToClass, getOnlineUserIds, emitToGuests } = require('../sse'); /* ── chat attachment uploads dir ─────────────────────────────────────── */ const CHAT_UPLOADS_DIR = path.join(__dirname, '../../uploads/chat'); if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true }); /* ── Draw permissions persisted in DB ─────────────────────────────────── */ function canDraw(sessionId, userId, session) { if (session.teacher_id === userId) return true; return !!db.prepare( 'SELECT 1 FROM classroom_draw_permissions WHERE session_id=? AND user_id=?' ).get(sessionId, userId); } /* 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', ]); /* ── 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); _raisedHands.delete(sessionId); db.prepare('DELETE FROM classroom_draw_permissions WHERE session_id=?').run(sessionId); emitToSession(sessionId, { type: 'classroom_ended', sessionId }); res.json({ ok: true }); } /* GET /api/classroom/class/:classId/active */ function getActiveSession(req, res) { const classId = Number(req.params.classId); if (req.user.role !== 'teacher' && req.user.role !== 'admin') { const isMember = db.prepare('SELECT 1 FROM class_members WHERE class_id=? AND user_id=?') .get(classId, req.user.id); if (!isMember) return res.status(403).json({ error: 'Нет доступа' }); } const session = db.prepare( `SELECT * FROM classroom_sessions WHERE class_id=? AND status='active' ORDER BY id DESC LIMIT 1` ).get(classId); if (!session) return res.json({ active: false }); res.json({ active: true, session }); } /* GET /api/classroom/my/active — personal sessions for current user */ function getMyActive(req, res) { const userId = req.user.id; const sessions = db.prepare(` SELECT s.* FROM classroom_sessions s JOIN classroom_invites i ON i.session_id = s.id WHERE i.user_id=? AND s.status='active' ORDER BY s.id DESC `).all(userId); res.json({ sessions }); } /* POST /api/classroom/:id/join */ function joinSession(req, res) { const sessionId = Number(req.params.id); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' }); db.prepare(` INSERT INTO classroom_attendance (session_id, user_id) VALUES (?,?) ON CONFLICT(session_id, user_id) DO UPDATE SET joined_at=datetime('now'), left_at=NULL `).run(sessionId, req.user.id); emitToSession(sessionId, { type: 'classroom_user_joined', sessionId, userId: req.user.id, userName: req.user.name, }); // If this user already has draw permission (e.g. they rejoined after a page refresh), notify them const drawAllowed = canDraw(sessionId, req.user.id, session) && session.teacher_id !== req.user.id; if (drawAllowed) { emit(req.user.id, { type: 'classroom_draw_permitted', sessionId }); } res.json({ ok: true, canDraw: drawAllowed }); } /* POST /api/classroom/:id/leave */ function leaveSession(req, res) { const sessionId = Number(req.params.id); db.prepare(`UPDATE classroom_attendance SET left_at=datetime('now') WHERE session_id=? AND user_id=? AND left_at IS NULL`) .run(sessionId, req.user.id); const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId); if (session) { emitToSession(sessionId, { type: 'classroom_user_left', sessionId, userId: req.user.id, }); } res.json({ ok: true }); } /* POST /api/classroom/:id/chat */ function sendChat(req, res) { const sessionId = Number(req.params.id); const { message = '', attachment_url, attachment_type } = req.body; const text = message.trim().slice(0, 2000); if (!text && !attachment_url) return res.status(400).json({ error: 'Пустое сообщение' }); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' }); const { lastInsertRowid } = db.prepare( 'INSERT INTO classroom_chat (session_id, user_id, message, attachment_url, attachment_type) VALUES (?,?,?,?,?)' ).run(sessionId, req.user.id, text, attachment_url || null, attachment_type || null); const row = db.prepare('SELECT * FROM classroom_chat WHERE id=?').get(lastInsertRowid); emitToSession(sessionId, { type: 'classroom_chat', sessionId, id: row.id, userId: req.user.id, userName: req.user.name, message: text, createdAt: row.created_at, attachmentUrl: row.attachment_url || null, attachmentType: row.attachment_type || null, }); res.json(row); } /* GET /api/classroom/:id/chat */ function getChat(req, res) { const sessionId = Number(req.params.id); const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId); if (!session) return res.status(404).json({ error: 'Не найдено' }); if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' }); const sinceId = Number(req.query.since_id) || 0; const messages = sinceId ? db.prepare(` SELECT c.*, u.name AS user_name FROM classroom_chat c JOIN users u ON u.id = c.user_id WHERE c.session_id=? AND c.id > ? ORDER BY c.id ASC LIMIT 100 `).all(sessionId, sinceId) : db.prepare(` SELECT c.*, u.name AS user_name FROM classroom_chat c JOIN users u ON u.id = c.user_id WHERE c.session_id=? ORDER BY c.id DESC LIMIT 200 `).all(sessionId).reverse(); // Attach reactions to each message if (messages.length > 0) { const ids = messages.map(m => m.id); const reactions = db.prepare( `SELECT chat_id, reaction, COUNT(*) AS cnt, GROUP_CONCAT(user_id) AS uids FROM classroom_chat_reactions WHERE chat_id IN (${ids.map(() => '?').join(',')}) GROUP BY chat_id, reaction` ).all(...ids); const rmap = {}; reactions.forEach(r => { if (!rmap[r.chat_id]) rmap[r.chat_id] = {}; rmap[r.chat_id][r.reaction] = { count: r.cnt, mine: (r.uids || '').split(',').includes(String(req.user.id)), }; }); messages.forEach(m => { m.reactions = rmap[m.id] || {}; }); } res.json({ messages }); } /* GET /api/classroom/:id/participants — active participants (for all session members) */ function getParticipants(req, res) { const sessionId = Number(req.params.id); const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId); if (!session) return res.status(404).json({ error: 'Не найдено' }); if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' }); const participants = db.prepare(` SELECT a.user_id, u.name, a.joined_at FROM classroom_attendance a JOIN users u ON u.id = a.user_id WHERE a.session_id=? AND a.left_at IS NULL ORDER BY a.joined_at `).all(sessionId); res.json({ participants }); } /* GET /api/classroom/:id/attendance */ function getAttendance(req, res) { const sessionId = Number(req.params.id); const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId); if (!session) return res.status(404).json({ error: 'Не найдено' }); if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); const attendance = db.prepare(` SELECT a.*, u.name AS user_name FROM classroom_attendance a JOIN users u ON u.id = a.user_id WHERE a.session_id=? ORDER BY a.joined_at `).all(sessionId); res.json({ attendance }); } /* POST /api/classroom/:id/signal — WebRTC signaling relay */ function signal(req, res) { const sessionId = Number(req.params.id); const { target_user_id, payload } = req.body; if (!target_user_id || !payload) return res.status(400).json({ error: 'target_user_id и payload обязательны' }); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' }); emit(target_user_id, { type: 'classroom_signal', sessionId, from: req.user.id, payload, }); res.json({ ok: true }); } /* GET /api/classroom/my/session — active session for current user (teacher or student) */ function getMySession(req, res) { const userId = req.user.id; const role = req.user.role; let session = null; if (role === 'teacher' || role === 'admin') { session = db.prepare( `SELECT * FROM classroom_sessions WHERE teacher_id=? AND status='active' ORDER BY id DESC LIMIT 1` ).get(userId); } else { // Class-based session: student is a member of a class that has an active session session = db.prepare(` SELECT cs.* FROM classroom_sessions cs JOIN class_members cm ON cm.class_id = cs.class_id WHERE cm.user_id=? AND cs.status='active' ORDER BY cs.id DESC LIMIT 1 `).get(userId); // Personal session: student was invited if (!session) { session = db.prepare(` SELECT cs.* FROM classroom_sessions cs JOIN classroom_invites ci ON ci.session_id = cs.id WHERE ci.user_id=? AND cs.status='active' ORDER BY cs.id DESC LIMIT 1 `).get(userId); } } if (!session) return res.json({ session: null }); const pageCount = db.prepare('SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?') .get(session.id).c; const attendance = db.prepare(` SELECT a.user_id, u.name, a.joined_at, a.left_at FROM classroom_attendance a JOIN users u ON u.id = a.user_id WHERE a.session_id=? ORDER BY a.joined_at `).all(session.id); // Did this user join before (even if they later left)? const wasJoined = attendance.some(a => a.user_id === userId); res.json({ session: { ...session, pageCount, attendance }, wasJoined }); } /* GET /api/classroom/online-students — list of students currently online (SSE connected) */ function getOnlineStudents(req, res) { const onlineIds = getOnlineUserIds(); if (!onlineIds.length) return res.json({ students: [] }); const placeholders = onlineIds.map(() => '?').join(','); const students = db.prepare( `SELECT id, name, email FROM users WHERE id IN (${placeholders}) AND role IN ('student','free_student') ORDER BY name` ).all(...onlineIds); res.json({ students }); } /* ── In-memory raised hands: sessionId -> Set ─────────────────── */ const _raisedHands = new Map(); /* POST /api/classroom/:id/pages — add a page */ function addPage(req, res) { const sessionId = Number(req.params.id); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); const maxFromStrokes = db.prepare( 'SELECT COALESCE(MAX(page_num), 1) AS m FROM classroom_strokes WHERE session_id=?' ).get(sessionId).m; const maxFromPages = db.prepare( 'SELECT COALESCE(MAX(page_num), 1) AS m FROM classroom_pages WHERE session_id=?' ).get(sessionId).m; const newPage = Math.max(session.current_page, maxFromStrokes, maxFromPages) + 1; const template = req.body?.template || 'blank'; db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)').run(sessionId, newPage, template); db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(newPage, sessionId); emitToSession(sessionId, { type: 'classroom_page_added', sessionId, pageNum: newPage, template }); res.json({ pageNum: newPage, template }); } /* PUT /api/classroom/:id/page — change current page */ function changePage(req, res) { const sessionId = Number(req.params.id); const { page_num } = req.body; if (!page_num) return res.status(400).json({ error: 'page_num required' }); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); db.prepare('UPDATE classroom_sessions SET current_page=? WHERE id=?').run(page_num, sessionId); emitToSession(sessionId, { type: 'classroom_page_changed', sessionId, pageNum: Number(page_num) }); res.json({ pageNum: Number(page_num) }); } /* PATCH /api/classroom/:id/page-template — update template for current page */ function updatePageTemplate(req, res) { const sessionId = Number(req.params.id); const { template } = req.body; if (!template) return res.status(400).json({ error: 'template required' }); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); db.prepare('UPDATE classroom_pages SET template=? WHERE session_id=? AND page_num=?') .run(template, sessionId, session.current_page); emitToSession(sessionId, { type: 'classroom_template_changed', sessionId, pageNum: session.current_page, template }); res.json({ ok: true, template }); } /* POST /api/classroom/:id/hand — raise hand */ function raiseHand(req, res) { const sessionId = Number(req.params.id); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' }); if (!_raisedHands.has(sessionId)) _raisedHands.set(sessionId, new Map()); _raisedHands.get(sessionId).set(req.user.id, req.user.name); emitToSession(sessionId, { type: 'classroom_hand_raised', sessionId, userId: req.user.id, userName: req.user.name, }); res.json({ ok: true }); } /* DELETE /api/classroom/:id/hand — lower hand */ function lowerHand(req, res) { const sessionId = Number(req.params.id); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=?`).get(sessionId); const map = _raisedHands.get(sessionId); if (map) map.delete(req.user.id); if (session) { emitToSession(sessionId, { type: 'classroom_hand_lowered', sessionId, userId: req.user.id }); } res.json({ ok: true }); } /* GET /api/classroom/:id/hands — get current raised hands */ function getHands(req, res) { const sessionId = Number(req.params.id); const map = _raisedHands.get(sessionId); const hands = map ? [...map.entries()].map(([userId, userName]) => ({ userId, userName })) : []; res.json({ hands }); } /* POST /api/classroom/:id/strokes — teacher saves batch of strokes */ function postStrokes(req, res) { const sessionId = Number(req.params.id); const { strokes, page_num = 1 } = req.body; if (!Array.isArray(strokes) || !strokes.length) return res.status(400).json({ error: 'strokes array required' }); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (!canDraw(sessionId, req.user.id, session) && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); // Get current max seq for this session+page const maxSeq = db.prepare( 'SELECT COALESCE(MAX(seq), 0) AS m FROM classroom_strokes WHERE session_id=? AND page_num=?' ).get(sessionId, page_num).m; const insert = db.prepare( 'INSERT INTO classroom_strokes (session_id, page_num, user_id, tool, data, seq) VALUES (?,?,?,?,?,?)' ); const saved = []; let seq = maxSeq; const insertMany = db.transaction(() => { for (const s of strokes) { seq++; const { lastInsertRowid } = insert.run(sessionId, page_num, req.user.id, s.tool || 'pencil', JSON.stringify(s.data), seq); saved.push({ id: Number(lastInsertRowid), tool: s.tool || 'pencil', data: s.data, seq }); } }); insertMany(); emitToSession(sessionId, { type: 'classroom_strokes', sessionId, pageNum: page_num, strokes: saved, userId: req.user.id, }); res.json({ strokes: saved }); } /* GET /api/classroom/:id/strokes?page_num=1&since_seq=N — load strokes for a page */ function getStrokes(req, res) { const sessionId = Number(req.params.id); const pageNum = Number(req.query.page_num) || 1; const sinceSeq = req.query.since_seq !== undefined ? Number(req.query.since_seq) : -1; const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId); if (!session) return res.status(404).json({ error: 'Не найдено' }); if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' }); const rows = sinceSeq >= 0 ? db.prepare('SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? AND seq > ? ORDER BY seq').all(sessionId, pageNum, sinceSeq) : db.prepare('SELECT id, tool, data, seq FROM classroom_strokes WHERE session_id=? AND page_num=? ORDER BY seq').all(sessionId, pageNum); const strokes = rows.map(r => ({ ...r, data: JSON.parse(r.data) })); const pageRow = db.prepare('SELECT template, 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 }); } /* 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) VALUES (?,?,?,?)'); db.transaction(() => { strokes.forEach(s => ins.run(sessionId, newPage, s.tool, s.data)); })(); 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.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: 'Нет доступа' }); emit(user_id, { type: 'classroom_muted', sessionId, by: req.user.id }); res.json({ ok: true }); } /* POST /api/classroom/:id/stroke-preview — broadcast live drawing state (not saved to DB) */ function previewStroke(req, res) { const sessionId = Number(req.params.id); const { live_id, tool, data, page_num = 1, cancel } = req.body; if (!live_id) return res.status(400).json({ error: 'live_id required' }); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (!canDraw(sessionId, req.user.id, session) && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); const payload = { type: 'classroom_stroke_preview', sessionId, pageNum: Number(page_num), liveId: live_id, tool, data, cancel: cancel || false, userId: req.user.id, userName: req.user.name || req.user.email, }; emitToSession(sessionId, payload); res.json({ ok: true }); } /* POST /api/classroom/:id/chat/:msgId/pin — teacher pins a message */ function pinMessage(req, res) { const sessionId = Number(req.params.id); const msgId = Number(req.params.msgId); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); const msg = db.prepare('SELECT * FROM classroom_chat WHERE id=? AND session_id=?').get(msgId, sessionId); if (!msg) return res.status(404).json({ error: 'Сообщение не найдено' }); // Toggle pin const newPinned = msg.pinned ? 0 : 1; db.prepare('UPDATE classroom_chat SET pinned=? WHERE id=?').run(newPinned, msgId); emitToSession(sessionId, { type: 'classroom_message_pinned', sessionId, msgId, pinned: !!newPinned, message: msg.message, }); res.json({ ok: true, pinned: !!newPinned }); } /* POST /api/classroom/:id/allow-draw/:userId — teacher grants draw permission */ function allowDraw(req, res) { const sessionId = Number(req.params.id); const targetId = Number(req.params.userId); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); db.prepare( 'INSERT OR IGNORE INTO classroom_draw_permissions (session_id, user_id) VALUES (?,?)' ).run(sessionId, targetId); emit(targetId, { type: 'classroom_draw_permitted', sessionId }); res.json({ ok: true }); } /* DELETE /api/classroom/:id/allow-draw/:userId — teacher revokes draw permission */ function revokeDraw(req, res) { const sessionId = Number(req.params.id); const targetId = Number(req.params.userId); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); db.prepare( 'DELETE FROM classroom_draw_permissions WHERE session_id=? AND user_id=?' ).run(sessionId, targetId); emit(targetId, { type: 'classroom_draw_revoked', sessionId }); res.json({ ok: true }); } /* POST /api/classroom/:id/cursor — teacher broadcasts cursor position */ function broadcastCursor(req, res) { const sessionId = Number(req.params.id); const { x, y, page_num = 1 } = req.body; const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (!hasAccess(session, req.user.id, req.user.role)) return res.status(403).json({ error: 'Нет доступа' }); emitToSession(sessionId, { type: 'classroom_cursor', sessionId, x, y, pageNum: Number(page_num), userId: req.user.id, userName: req.user.name || req.user.email, }); res.json({ ok: true }); } /* POST /api/classroom/:id/screen — teacher announces screen share start */ function screenStart(req, res) { const sessionId = Number(req.params.id); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); emitToSession(sessionId, { type: 'classroom_screen_started', sessionId }); res.json({ ok: true }); } /* DELETE /api/classroom/:id/screen — teacher announces screen share stop */ function screenStop(req, res) { const sessionId = Number(req.params.id); const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId); if (!session) return res.status(404).json({ error: 'Сессия не активна' }); if (session.teacher_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Нет доступа' }); emitToSession(sessionId, { type: 'classroom_screen_stopped', sessionId }); res.json({ ok: true }); } /* ── 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' }); 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 }); } /* 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 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: 'Нет доступа' }); 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_sessions WHERE id=?').run(sessionId); 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=?').get(template_id); if (!tmpl) return res.status(404).json({ error: 'Шаблон не найден' }); const pagesData = JSON.parse(tmpl.pages_data || '[]'); // Clear current session data db.prepare('DELETE FROM classroom_strokes WHERE session_id=?').run(sessionId); db.prepare('DELETE FROM classroom_pages WHERE session_id=?').run(sessionId); // Restore from template pagesData.forEach(p => { db.prepare('INSERT OR IGNORE INTO classroom_pages (session_id, page_num, template) VALUES (?,?,?)') .run(sessionId, p.page_num, p.template || 'blank'); (p.strokes || []).forEach(s => { db.prepare('INSERT INTO classroom_strokes (session_id, page_num, tool, data) VALUES (?,?,?,?)') .run(sessionId, p.page_num, s.tool, JSON.stringify(s.data)); }); }); // Broadcast: clients reload emitToSession(sessionId, { type: 'classroom_template_loaded', sessionId, pages: pagesData.length, }); res.json({ ok: true, pages: pagesData.length }); } /* ── 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}` }); } module.exports = { createSession, getSession, endSession, getActiveSession, getMyActive, joinSession, leaveSession, sendChat, getChat, getAttendance, getParticipants, signal, getOnlineStudents, getMySession, postStrokes, getStrokes, deleteStroke, updateStroke, addPage, changePage, updatePageTemplate, getPages, renamePage, duplicatePage, deletePage, raiseHand, lowerHand, getHands, mutePeer, screenStart, screenStop, simOpen, simClose, simState, simMode, 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, };