feat: WebSocket real-time + rAF render gate + guest board + screen picker

Classroom performance:
- WebSocket server (ws-server.js) for low-latency cursor & stroke preview
  Replaces HTTP POST per event → eliminates per-message auth overhead
  Session member cache (30s TTL) avoids SQLite query per WS message
  Fallback to HTTP POST when WS not connected
- Cursor throttle reduced 100ms → 33ms (~30fps)
- Stroke preview throttle reduced 50ms → 20ms
- whiteboard.js: render() is now rAF-gated (_doRender/_rafPending)
  Multiple render() calls within one frame collapse into one repaint
  document.hidden check — zero CPU when tab is in background
  visibilitychange listener restores canvas on tab focus

Guest board:
- guestClassroom.js route: public token-based read-only access
- guest-board.html: name entry + read-only whiteboard + SSE
- SSE: addGuestClient/removeGuestClient/emitToGuests

Screen share picker:
- Discord-style modal with tab switching (screen/window/tab)
- Live video preview before confirming share
- useExistingScreenStream() in ClassroomRTC

Fullscreen exit overlay:
- #cr-fs-exit-overlay button inside cr-board-wrap
- Visible only via CSS :fullscreen selector (touchpad users)

File sharing from library:
- Teacher picks file from library, sends as styled card in chat
- crDownloadLibraryFile() fetches with Bearer auth

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-13 18:04:59 +03:00
parent 074ee5687b
commit fd29acbbdd
70 changed files with 12231 additions and 498 deletions
+568 -3
View File
@@ -1,7 +1,8 @@
const db = require('../db/db');
const path = require('path');
const fs = require('fs');
const { emit, emitToClass, getOnlineUserIds } = require('../sse');
const crypto = require('crypto');
const { emit, emitToClass, getOnlineUserIds, emitToGuests } = require('../sse');
/* ── chat attachment uploads dir ─────────────────────────────────────── */
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../uploads/chat');
@@ -15,6 +16,14 @@ function canDraw(sessionId, userId, session) {
).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 ─────────────────────── */
function emitToSession(sessionId, data) {
const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
@@ -29,6 +38,9 @@ function emitToSession(sessionId, data) {
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
for (const { user_id } of invites) emit(user_id, data);
}
// Forward whitelisted events to guest viewers
if (GUEST_EVENTS.has(data.type)) emitToGuests(sessionId, data);
}
/* ── Helper: check if user has access to session ──────────────────────── */
@@ -50,6 +62,12 @@ 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' });
}
@@ -585,10 +603,11 @@ function getStrokes(req, res) {
const strokes = rows.map(r => ({ ...r, data: JSON.parse(r.data) }));
const pageRow = db.prepare('SELECT template FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum);
const 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 });
res.json({ strokes, template, name });
}
/* PATCH /api/classroom/:id/strokes/:strokeId — update image position/size */
@@ -656,6 +675,103 @@ function clearPage(req, res) {
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);
@@ -797,6 +913,66 @@ function screenStop(req, res) {
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: 'Файл не получен' });
@@ -870,7 +1046,341 @@ function saveNotes(req, res) {
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'
@@ -953,6 +1463,41 @@ function loadTemplate(req, res) {
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,
@@ -975,12 +1520,20 @@ module.exports = {
addPage,
changePage,
updatePageTemplate,
getPages,
renamePage,
duplicatePage,
deletePage,
raiseHand,
lowerHand,
getHands,
mutePeer,
screenStart,
screenStop,
simOpen,
simClose,
simState,
simMode,
clearPage,
previewStroke,
broadcastCursor,
@@ -995,4 +1548,16 @@ module.exports = {
saveTemplate,
deleteTemplate,
loadTemplate,
getClassHistory,
getMyHistory,
getSessionSummary,
exportChat,
getAllNotes,
deleteHistorySession,
adminGetActiveSessions,
adminGetAllSessions,
adminGetTeachersList,
generateGuestToken,
revokeGuestToken,
getGuestToken,
};