7f23cfdacd
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1583 lines
67 KiB
JavaScript
1583 lines
67 KiB
JavaScript
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',
|
|
'classroom_board_theme',
|
|
]);
|
|
|
|
/* ── Helper: broadcast to all session participants ─────────────────────── */
|
|
/* Delivery: WS-first (ws-server.js) with SSE fallback for non-WS users. */
|
|
function emitToSession(sessionId, data) {
|
|
// ws-server handles WS-first + SSE-fallback + guest forwarding internally
|
|
require('../ws-server').broadcastToSession(sessionId, data, GUEST_EVENTS.has(data.type));
|
|
}
|
|
|
|
/* ── Helper: check if user has access to session ──────────────────────── */
|
|
function hasAccess(session, userId, userRole) {
|
|
if (userRole === 'admin') return true;
|
|
if (session.teacher_id === userId) return true;
|
|
|
|
if (session.class_id) {
|
|
return !!db.prepare('SELECT 1 FROM class_members WHERE class_id=? AND user_id=?')
|
|
.get(session.class_id, userId);
|
|
} else {
|
|
return !!db.prepare('SELECT 1 FROM classroom_invites WHERE session_id=? AND user_id=?')
|
|
.get(session.id, userId);
|
|
}
|
|
}
|
|
|
|
/* POST /api/classroom — teacher creates session */
|
|
function createSession(req, res) {
|
|
const { class_id, user_ids, title = '' } = req.body;
|
|
const teacher = req.user;
|
|
|
|
// Check if classroom module is enabled
|
|
const classroomEnabled = db.prepare("SELECT value FROM app_settings WHERE key='feature_classroom_enabled'").get();
|
|
if (classroomEnabled?.value === '0') {
|
|
return res.status(403).json({ error: 'Модуль онлайн-уроков отключён администратором' });
|
|
}
|
|
|
|
if (!class_id && (!user_ids || !user_ids.length)) {
|
|
return res.status(400).json({ error: 'Укажите class_id или user_ids' });
|
|
}
|
|
|
|
if (class_id) {
|
|
// verify teacher owns class
|
|
const cls = teacher.role === 'admin'
|
|
? db.prepare('SELECT id, name FROM classes WHERE id=?').get(class_id)
|
|
: db.prepare('SELECT id, name FROM classes WHERE id=? AND teacher_id=?').get(class_id, teacher.id);
|
|
if (!cls) return res.status(403).json({ error: 'Нет доступа к классу' });
|
|
|
|
// end any active session for this class
|
|
db.prepare(`UPDATE classroom_sessions SET status='ended', ended_at=datetime('now')
|
|
WHERE class_id=? AND status='active'`).run(class_id);
|
|
}
|
|
|
|
const { lastInsertRowid } = db.prepare(
|
|
`INSERT INTO classroom_sessions (class_id, teacher_id, title) VALUES (?,?,?)`
|
|
).run(class_id || null, teacher.id, title);
|
|
|
|
const sessionId = Number(lastInsertRowid);
|
|
|
|
// create first page
|
|
db.prepare('INSERT INTO classroom_pages (session_id, page_num) VALUES (?,1)').run(sessionId);
|
|
|
|
// for personal sessions — save invites
|
|
if (!class_id && user_ids) {
|
|
const ins = db.prepare('INSERT OR IGNORE INTO classroom_invites (session_id, user_id) VALUES (?,?)');
|
|
for (const uid of user_ids) ins.run(sessionId, uid);
|
|
}
|
|
|
|
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
|
|
|
emitToSession(sessionId, {
|
|
type: 'classroom_started',
|
|
sessionId,
|
|
title,
|
|
classId: class_id || null,
|
|
teacherName: teacher.name,
|
|
});
|
|
|
|
res.json(session);
|
|
}
|
|
|
|
/* GET /api/classroom/:id */
|
|
function getSession(req, res) {
|
|
const sessionId = Number(req.params.id);
|
|
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
|
if (!session) return res.status(404).json({ error: 'Не найдено' });
|
|
|
|
if (!hasAccess(session, req.user.id, req.user.role))
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
|
|
const pageCount = db.prepare('SELECT COUNT(*) AS c FROM classroom_pages WHERE session_id=?')
|
|
.get(sessionId).c;
|
|
|
|
const attendance = db.prepare(`
|
|
SELECT a.user_id, u.name, a.joined_at, a.left_at
|
|
FROM classroom_attendance a
|
|
JOIN users u ON u.id = a.user_id
|
|
WHERE a.session_id=? ORDER BY a.joined_at
|
|
`).all(sessionId);
|
|
|
|
const drawAllowed = canDraw(sessionId, req.user.id, session);
|
|
res.json({ ...session, pageCount, attendance, canDraw: drawAllowed });
|
|
}
|
|
|
|
/* DELETE /api/classroom/:id */
|
|
function endSession(req, res) {
|
|
const sessionId = Number(req.params.id);
|
|
const session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(sessionId);
|
|
if (!session) return res.status(404).json({ error: 'Не найдено' });
|
|
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
|
|
db.prepare(`UPDATE classroom_sessions SET status='ended', ended_at=datetime('now') WHERE id=?`)
|
|
.run(sessionId);
|
|
|
|
_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<userId> ─────────────────── */
|
|
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 });
|
|
}
|
|
|
|
/* PATCH /api/classroom/:id/board-theme — change board theme and broadcast to all */
|
|
function updateBoardTheme(req, res) {
|
|
const sessionId = Number(req.params.id);
|
|
const VALID_THEMES = new Set(['chalkboard', 'blackboard', 'whiteboard', 'dark', 'grid', 'dots']);
|
|
const { theme } = req.body;
|
|
if (!theme || !VALID_THEMES.has(theme)) return res.status(400).json({ error: 'invalid theme' });
|
|
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
|
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
|
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
db.prepare('UPDATE classroom_sessions SET board_theme=? WHERE id=?').run(theme, sessionId);
|
|
emitToSession(sessionId, { type: 'classroom_board_theme', sessionId, theme });
|
|
res.json({ ok: true, theme });
|
|
}
|
|
|
|
/* POST /api/classroom/:id/hand — raise hand */
|
|
function raiseHand(req, res) {
|
|
const sessionId = Number(req.params.id);
|
|
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
|
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
|
if (!hasAccess(session, req.user.id, req.user.role))
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
|
|
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 });
|
|
}
|
|
|
|
/* POST /api/classroom/:id/sim/annotate — teacher toggles draw-over-sim mode */
|
|
function simAnnotate(req, res) {
|
|
const sessionId = Number(req.params.id);
|
|
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
|
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
|
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
|
|
const { active } = req.body;
|
|
emitToSession(sessionId, { type: 'classroom_sim_annotate', sessionId, active: !!active });
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
/* DELETE /api/classroom/:id/sim — teacher closes simulation */
|
|
function simClose(req, res) {
|
|
const sessionId = Number(req.params.id);
|
|
const session = db.prepare(`SELECT * FROM classroom_sessions WHERE id=? AND status='active'`).get(sessionId);
|
|
if (!session) return res.status(404).json({ error: 'Сессия не активна' });
|
|
if (session.teacher_id !== req.user.id && req.user.role !== 'admin')
|
|
return res.status(403).json({ error: 'Нет доступа' });
|
|
|
|
emitToSession(sessionId, { type: 'classroom_sim_close', sessionId });
|
|
res.json({ ok: true });
|
|
}
|
|
|
|
/* ── Chat: upload image attachment ──────────────────────────────────────── */
|
|
function uploadChatAttachment(req, res) {
|
|
if (!req.file) return res.status(400).json({ error: 'Файл не получен' });
|
|
const url = `/uploads/chat/${req.file.filename}`;
|
|
const type = req.file.mimetype.startsWith('image/') ? 'image' : 'file';
|
|
res.json({ url, type, name: req.file.originalname });
|
|
}
|
|
|
|
/* ── Chat: toggle reaction ───────────────────────────────────────────────── */
|
|
const ALLOWED_REACTIONS = ['like', 'heart', 'question', 'idea', 'wow'];
|
|
function reactToMessage(req, res) {
|
|
const chatId = Number(req.params.msgId);
|
|
const { reaction } = req.body;
|
|
if (!ALLOWED_REACTIONS.includes(reaction))
|
|
return res.status(400).json({ error: 'Неизвестная реакция' });
|
|
|
|
const msg = db.prepare('SELECT * FROM classroom_chat WHERE id=?').get(chatId);
|
|
if (!msg) return res.status(404).json({ error: 'Сообщение не найдено' });
|
|
|
|
const 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,
|
|
updateBoardTheme,
|
|
getPages,
|
|
renamePage,
|
|
duplicatePage,
|
|
deletePage,
|
|
raiseHand,
|
|
lowerHand,
|
|
getHands,
|
|
mutePeer,
|
|
screenStart,
|
|
screenStop,
|
|
simOpen,
|
|
simClose,
|
|
simState,
|
|
simMode,
|
|
simAnnotate,
|
|
clearPage,
|
|
previewStroke,
|
|
broadcastCursor,
|
|
pinMessage,
|
|
allowDraw,
|
|
revokeDraw,
|
|
uploadChatAttachment,
|
|
reactToMessage,
|
|
getNotes,
|
|
saveNotes,
|
|
getTemplates,
|
|
saveTemplate,
|
|
deleteTemplate,
|
|
loadTemplate,
|
|
getClassHistory,
|
|
getMyHistory,
|
|
getSessionSummary,
|
|
exportChat,
|
|
getAllNotes,
|
|
deleteHistorySession,
|
|
adminGetActiveSessions,
|
|
adminGetAllSessions,
|
|
adminGetTeachersList,
|
|
generateGuestToken,
|
|
revokeGuestToken,
|
|
getGuestToken,
|
|
};
|