Files
Learn_System/backend/src/controllers/classroom/chat.js
T
Maxim Dolgolyov c0f20ef020 fix: classroom review — 11 исправлений из code review
- sessions.js: endSession закрывает classroom_attendance (left_at), чистит classroom_muted
- sessions.js: joinSession восстанавливает mute-состояние при реконнекте
- strokes.js: updateStroke проверяет авторство штриха (не только canDraw)
- strokes.js: clearPage валидирует page_num как положительное целое
- strokes.js: postStrokes ограничивает массив 500 штрихами
- pages.js: duplicatePage сохраняет user_id при копировании штрихов
- pages.js: changePage валидирует page_num
- pages.js: updatePageTemplate делает INSERT OR IGNORE перед UPDATE
- permissions.js: mutePeer сохраняет в classroom_muted; добавлен unmutePeer
- permissions.js: getOnlineStudents не возвращает email
- chat.js: exportChat экранирует переводы строк в именах и сообщениях
- guestClassroom.js: санитизация имени гостя (убираем HTML-символы)
- ws-server.js: mute_peer сохраняет в БД; добавлен обработчик unmute_peer
- routes/classroom.js: rate-limit для cursor/preview/signal/strokes; маршрут DELETE /mute
- migrations/001_classroom_muted.sql: новая таблица classroom_muted
2026-05-07 14:26:19 +03:00

161 lines
7.7 KiB
JavaScript

'use strict';
const db = require('../../db/db');
const path = require('path');
const fs = require('fs');
const { emitToUser } = require('../../ws-server');
const { emitToSession, hasAccess } = require('./_shared');
const CHAT_UPLOADS_DIR = path.join(__dirname, '../../../uploads/chat');
if (!fs.existsSync(CHAT_UPLOADS_DIR)) fs.mkdirSync(CHAT_UPLOADS_DIR, { recursive: true });
const ALLOWED_REACTIONS = ['like', 'heart', 'question', 'idea', 'wow'];
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);
const safeUrl = attachment_url && typeof attachment_url === 'string' && attachment_url.startsWith('/uploads/')
? attachment_url : null;
if (!text && !safeUrl) 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, safeUrl, safeUrl ? (attachment_type || null) : 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);
}
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();
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 });
}
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: 'Сообщение не найдено' });
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 });
}
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 session = db.prepare('SELECT * FROM classroom_sessions WHERE id=?').get(msg.session_id);
if (!session || !hasAccess(session, req.user.id, req.user.role))
return res.status(403).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 });
}
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 });
}
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' }) : '';
const safeUser = (m.user_name || '').replace(/[\r\n]/g, ' ');
const safeMsg = (m.message || '').replace(/[\r\n]/g, ' ');
text += `[${ts}] ${safeUser}: ${safeMsg}`;
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);
}
module.exports = {
CHAT_UPLOADS_DIR, ALLOWED_REACTIONS,
sendChat, getChat, pinMessage, reactToMessage, uploadChatAttachment, exportChat,
};