LearnSpace: full-stack educational whiteboard platform

Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
@@ -0,0 +1,998 @@
const db = require('../db/db');
const path = require('path');
const fs = require('fs');
const { emit, emitToClass, getOnlineUserIds } = 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);
}
/* ── Helper: broadcast to all session participants ─────────────────────── */
function emitToSession(sessionId, data) {
const session = db.prepare('SELECT class_id, teacher_id FROM classroom_sessions WHERE id=?').get(sessionId);
if (!session) return;
if (session.class_id) {
emitToClass(session.class_id, data);
emit(session.teacher_id, data); // teacher is not in class_members — emit separately
} else {
// personal session — emit to teacher + each invited user
emit(session.teacher_id, data);
const invites = db.prepare('SELECT user_id FROM classroom_invites WHERE session_id=?').all(sessionId);
for (const { user_id } of invites) emit(user_id, data);
}
}
/* ── 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;
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 });
}
/* 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 FROM classroom_pages WHERE session_id=? AND page_num=?').get(sessionId, pageNum);
const template = pageRow?.template || 'blank';
res.json({ strokes, template });
}
/* 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 });
}
/* 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 });
}
/* ── 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 templates ───────────────────────────────────────────────────── */
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 });
}
module.exports = {
createSession,
getSession,
endSession,
getActiveSession,
getMyActive,
joinSession,
leaveSession,
sendChat,
getChat,
getAttendance,
getParticipants,
signal,
getOnlineStudents,
getMySession,
postStrokes,
getStrokes,
deleteStroke,
updateStroke,
addPage,
changePage,
updatePageTemplate,
raiseHand,
lowerHand,
getHands,
mutePeer,
screenStart,
screenStop,
clearPage,
previewStroke,
broadcastCursor,
pinMessage,
allowDraw,
revokeDraw,
uploadChatAttachment,
reactToMessage,
getNotes,
saveNotes,
getTemplates,
saveTemplate,
deleteTemplate,
loadTemplate,
};