Files
Learn_System/backend/src/controllers/classroom/sessions.js
T
Maxim Dolgolyov 758e1bf6cb feat(dashboard): статус «идёт онлайн-урок» с присоединением
На дашборде ученика/учителя — баннер активной classroom-сессии: заголовок урока,
для учителя «N онлайн», для ученика «Присоединиться/Вернуться», ссылка на /classroom
(там сессия подхватывается автоматически). Данные — LS.crGetMySession (учитель → своя
сессия, ученик → сессия его класса/приглашения). Нет активной сессии → баннер скрыт.

Доска работает по WebSocket, дашборд — по SSE, поэтому добавлен отдельный SSE-сигнал
classroom_live (state started/ended) ученикам класса/приглашённым/учителю в createSession
и endSession (аддитивно, в try/catch — не ломает создание/завершение сессии). Баннер
живо появляется/исчезает по этому событию + обновляется при возврате на вкладку.

Verified: рендер баннера 10/10 (ученик/учитель/нет сессии, online-счёт без вышедших,
пустой title→«Онлайн-урок»); node --check sessions.js + инлайна dashboard; sse-путь резолвится.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:24:14 +03:00

245 lines
12 KiB
JavaScript

'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 });
// Баннер «идёт онлайн-урок» на дашбордах — через SSE-канал (доска работает по WS,
// дашборд по SSE, поэтому нужен отдельный сигнал ученикам класса / приглашённым / учителю).
try {
const sse = require('../../sse');
const payload = { type: 'classroom_live', state: 'started', sessionId, title, classId: class_id || null };
if (class_id) sse.emitToClass(class_id, payload);
else if (user_ids) for (const uid of user_ids) sse.emit(uid, payload);
sse.emit(teacher.id, payload);
} catch { /* SSE недоступен — не критично */ }
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(`UPDATE classroom_attendance SET left_at=datetime('now') WHERE session_id=? AND left_at IS NULL`).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);
db.prepare('DELETE FROM classroom_muted WHERE session_id=?').run(sessionId);
emitToSession(sessionId, { type: 'classroom_ended', sessionId });
// Снять баннер «идёт онлайн-урок» с дашбордов (SSE-канал).
try {
const sse = require('../../sse');
const payload = { type: 'classroom_live', state: 'ended', sessionId };
if (session.class_id) sse.emitToClass(session.class_id, payload);
else {
const invited = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
for (const r of invited) sse.emit(r.user_id, payload);
}
sse.emit(session.teacher_id, payload);
} catch { /* SSE недоступен — не критично */ }
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 });
const isMuted = !!db.prepare('SELECT 1 FROM classroom_muted WHERE session_id=? AND user_id=?').get(sessionId, req.user.id);
if (isMuted) emitToUser(req.user.id, { type: 'classroom_muted', sessionId, by: null });
res.json({ ok: true, canDraw: drawAllowed, muted: isMuted });
}
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,
};