758e1bf6cb
На дашборде ученика/учителя — баннер активной 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>
245 lines
12 KiB
JavaScript
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,
|
|
};
|