refactor: split classroomController.js into 7 domain files (phase 2 of 2)
1618-line monolith split into: classroom/_shared.js — GUEST_EVENTS, emitToSession, hasAccess, canDraw classroom/sessions.js — lifecycle + guest tokens (12 functions) classroom/strokes.js — CRUD + cursor + preview (7 functions) classroom/pages.js — page CRUD + theme (8 functions) classroom/chat.js — messages, reactions, attachments, export (7 functions) classroom/permissions.js — draw, hand, mute, screen, attendance (11 functions) classroom/sim.js — simulation relay (5 functions) classroom/admin.js — history, notes, templates, admin views (14 functions) classroomController.js is now a 9-line re-export facade. routes/classroom.js unchanged. All 65 exports verified. Tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
'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' }) : '';
|
||||
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);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CHAT_UPLOADS_DIR, ALLOWED_REACTIONS,
|
||||
sendChat, getChat, pinMessage, reactToMessage, uploadChatAttachment, exportChat,
|
||||
};
|
||||
Reference in New Issue
Block a user