diff --git a/api-chat.js b/api-chat.js new file mode 100644 index 0000000..1c69ed4 --- /dev/null +++ b/api-chat.js @@ -0,0 +1,506 @@ +// api-chat.js - API для чата задач +const express = require('express'); +const router = express.Router(); +const path = require('path'); +const fs = require('fs'); + +module.exports = function(app, db, upload) { + // Получаем функции из database.js + let logActivity, checkTaskAccess; + + // Пытаемся импортировать функции + try { + const dbModule = require('./database'); + logActivity = dbModule.logActivity; + checkTaskAccess = dbModule.checkTaskAccess; + console.log('✅ Функции database.js загружены в api-chat'); + } catch (error) { + console.error('❌ Ошибка загрузки функций из database.js:', error); + // Создаем заглушки + logActivity = (taskId, userId, action, details) => { + console.log(`[LOG] Task ${taskId}, User ${userId}: ${action} - ${details}`); + }; + checkTaskAccess = (userId, taskId, callback) => { + // Заглушка - даем доступ всем + callback(null, true); + }; + } + + // Middleware для аутентификации + const requireAuth = (req, res, next) => { + if (!req.session || !req.session.user) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + next(); + }; + + // GET /api/chat/tasks/:taskId/messages - Получить сообщения чата задачи + router.get('/api/chat/tasks/:taskId/messages', requireAuth, (req, res) => { + const { taskId } = req.params; + const userId = req.session.user.id; + const { before, limit = 50 } = req.query; + + console.log(`📨 Запрос сообщений для задачи ${taskId} от пользователя ${userId}`); + + // Проверяем доступ к задаче + checkTaskAccess(userId, taskId, (err, hasAccess) => { + if (err) { + console.error('❌ Ошибка проверки доступа:', err); + return res.status(500).json({ error: 'Ошибка проверки доступа' }); + } + + if (!hasAccess) { + return res.status(403).json({ error: 'Нет доступа к задаче' }); + } + + // Сначала проверим существование таблицы + db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='task_chat_messages'", [], (tableErr, tableExists) => { + if (tableErr || !tableExists) { + console.error('❌ Таблица task_chat_messages не существует'); + return res.status(500).json({ error: 'Таблица чата не создана', messages: [], hasMore: false }); + } + + let query = ` + SELECT + m.*, + u.name as user_name, + u.login as user_login, + rm.message as reply_to_message, + ru.name as reply_to_user_name + FROM task_chat_messages m + LEFT JOIN users u ON m.user_id = u.id + LEFT JOIN task_chat_messages rm ON m.reply_to_id = rm.id + LEFT JOIN users ru ON rm.user_id = ru.id + WHERE m.task_id = ? AND m.is_deleted = 0 + `; + + const params = [taskId]; + + if (before) { + query += ` AND m.created_at < ?`; + params.push(before); + } + + query += ` ORDER BY m.created_at DESC LIMIT ?`; + params.push(parseInt(limit)); + + db.all(query, params, (err, messages) => { + if (err) { + console.error('❌ Ошибка получения сообщений:', err); + return res.status(500).json({ error: 'Ошибка получения сообщений', details: err.message }); + } + + // Получаем файлы для каждого сообщения + const messageIds = messages.map(m => m.id); + + if (messageIds.length === 0) { + return res.json({ messages: [], hasMore: false }); + } + + const placeholders = messageIds.map(() => '?').join(','); + + db.all(` + SELECT * FROM task_chat_files + WHERE message_id IN (${placeholders}) + `, messageIds, (fileErr, files) => { + if (fileErr) { + console.error('❌ Ошибка получения файлов:', fileErr); + } + + // Группируем файлы по сообщениям + const filesByMessage = {}; + if (files) { + files.forEach(file => { + if (!filesByMessage[file.message_id]) { + filesByMessage[file.message_id] = []; + } + filesByMessage[file.message_id].push(file); + }); + } + + // Добавляем файлы к сообщениям + const messagesWithFiles = messages.map(msg => ({ + ...msg, + files: filesByMessage[msg.id] || [] + })); + + // Помечаем сообщения как прочитанные + if (messagesWithFiles.length > 0) { + const unreadMessageIds = messagesWithFiles + .filter(m => m.user_id != userId) + .map(m => m.id); + + if (unreadMessageIds.length > 0) { + const unreadPlaceholders = unreadMessageIds.map(() => '?').join(','); + db.run(` + INSERT OR IGNORE INTO task_chat_reads (message_id, user_id) + SELECT id, ? FROM task_chat_messages + WHERE id IN (${unreadPlaceholders}) + `, [userId, ...unreadMessageIds], (readErr) => { + if (readErr) console.error('❌ Ошибка отметки прочитанных:', readErr); + }); + } + } + + res.json({ + messages: messagesWithFiles, + hasMore: messages.length === parseInt(limit) + }); + }); + }); + }); + }); + }); + + // POST /api/chat/tasks/:taskId/messages - Отправить сообщение + router.post('/api/chat/tasks/:taskId/messages', requireAuth, upload.array('files', 5), (req, res) => { + const { taskId } = req.params; + const { message, reply_to_id } = req.body; + const userId = req.session.user.id; + + console.log(`📝 Отправка сообщения в задачу ${taskId} от пользователя ${userId}`); + + if (!message || message.trim() === '') { + return res.status(400).json({ error: 'Сообщение не может быть пустым' }); + } + + // Проверяем доступ к задаче + checkTaskAccess(userId, taskId, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(403).json({ error: 'Нет доступа к задаче' }); + } + + // Проверяем существование таблицы + db.get("SELECT name FROM sqlite_master WHERE type='table' AND name='task_chat_messages'", [], (tableErr, tableExists) => { + if (tableErr || !tableExists) { + console.error('❌ Таблица task_chat_messages не существует'); + return res.status(500).json({ error: 'Система чата не инициализирована' }); + } + + // Вставляем сообщение + db.run( + `INSERT INTO task_chat_messages (task_id, user_id, message, reply_to_id) + VALUES (?, ?, ?, ?)`, + [taskId, userId, message.trim(), reply_to_id || null], + function(err) { + if (err) { + console.error('❌ Ошибка создания сообщения:', err); + return res.status(500).json({ error: 'Ошибка создания сообщения' }); + } + + const messageId = this.lastID; + const uploadedFiles = []; + + // Если есть файлы, сохраняем их + if (req.files && req.files.length > 0) { + const chatDir = path.join(__dirname, 'data', 'uploads', 'chat', taskId.toString()); + if (!fs.existsSync(chatDir)) { + fs.mkdirSync(chatDir, { recursive: true }); + } + + let filesProcessed = 0; + + req.files.forEach((file, index) => { + const fileExt = path.extname(file.originalname); + const fileName = `${messageId}_${Date.now()}_${index}${fileExt}`; + const filePath = path.join(chatDir, fileName); + + try { + fs.renameSync(file.path, filePath); + } catch (renameErr) { + console.error('❌ Ошибка перемещения файла:', renameErr); + } + + db.run( + `INSERT INTO task_chat_files (message_id, file_path, original_name, file_size, file_type) + VALUES (?, ?, ?, ?, ?)`, + [messageId, filePath, file.originalname, file.size, file.mimetype], + function(fileErr) { + if (!fileErr) { + uploadedFiles.push({ + id: this.lastID, + original_name: file.originalname, + file_size: file.size, + file_type: file.mimetype + }); + } + + filesProcessed++; + if (filesProcessed === req.files.length) { + finishTransaction(); + } + } + ); + }); + } else { + finishTransaction(); + } + + function finishTransaction() { + // Логируем действие + if (logActivity) { + logActivity(parseInt(taskId), userId, 'CHAT_MESSAGE', 'Отправлено сообщение в чат'); + } + + // Получаем полную информацию о сообщении + db.get(` + SELECT + m.*, + u.name as user_name, + u.login as user_login, + rm.message as reply_to_message, + ru.name as reply_to_user_name + FROM task_chat_messages m + LEFT JOIN users u ON m.user_id = u.id + LEFT JOIN task_chat_messages rm ON m.reply_to_id = rm.id + LEFT JOIN users ru ON rm.user_id = ru.id + WHERE m.id = ? + `, [messageId], (err, newMessage) => { + if (err) { + console.error('❌ Ошибка получения сообщения:', err); + return res.json({ + success: true, + messageId, + files: uploadedFiles + }); + } + + newMessage.files = uploadedFiles; + + // Отправляем уведомления участникам задачи + notifyTaskParticipants(taskId, userId, newMessage); + + res.json({ + success: true, + message: newMessage + }); + }); + } + } + ); + }); + }); + }); + + // PUT /api/chat/messages/:messageId - Редактировать сообщение + router.put('/api/chat/messages/:messageId', requireAuth, (req, res) => { + const { messageId } = req.params; + const { message } = req.body; + const userId = req.session.user.id; + + if (!message || message.trim() === '') { + return res.status(400).json({ error: 'Сообщение не может быть пустым' }); + } + + db.get( + 'SELECT task_id, user_id FROM task_chat_messages WHERE id = ? AND is_deleted = 0', + [messageId], + (err, msg) => { + if (err || !msg) { + return res.status(404).json({ error: 'Сообщение не найдено' }); + } + + // Проверяем, что пользователь - автор сообщения или админ + if (parseInt(msg.user_id) !== parseInt(userId) && req.session.user.role !== 'admin') { + return res.status(403).json({ error: 'Нет прав для редактирования этого сообщения' }); + } + + db.run( + `UPDATE task_chat_messages + SET message = ?, is_edited = 1, updated_at = CURRENT_TIMESTAMP + WHERE id = ?`, + [message.trim(), messageId], + function(err) { + if (err) { + console.error('❌ Ошибка редактирования сообщения:', err); + return res.status(500).json({ error: 'Ошибка редактирования сообщения' }); + } + + if (logActivity) { + logActivity(msg.task_id, userId, 'CHAT_MESSAGE_EDITED', 'Сообщение отредактировано'); + } + + res.json({ + success: true, + message: 'Сообщение отредактировано' + }); + } + ); + } + ); + }); + + // DELETE /api/chat/messages/:messageId - Удалить сообщение (soft delete) + router.delete('/api/chat/messages/:messageId', requireAuth, (req, res) => { + const { messageId } = req.params; + const userId = req.session.user.id; + + db.get( + 'SELECT task_id, user_id FROM task_chat_messages WHERE id = ? AND is_deleted = 0', + [messageId], + (err, msg) => { + if (err || !msg) { + return res.status(404).json({ error: 'Сообщение не найдено' }); + } + + // Проверяем, что пользователь - автор сообщения или админ + if (parseInt(msg.user_id) !== parseInt(userId) && req.session.user.role !== 'admin') { + return res.status(403).json({ error: 'Нет прав для удаления этого сообщения' }); + } + + db.run( + 'UPDATE task_chat_messages SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?', + [messageId], + function(err) { + if (err) { + console.error('❌ Ошибка удаления сообщения:', err); + return res.status(500).json({ error: 'Ошибка удаления сообщения' }); + } + + if (logActivity) { + logActivity(msg.task_id, userId, 'CHAT_MESSAGE_DELETED', 'Сообщение удалено'); + } + + res.json({ + success: true, + message: 'Сообщение удалено' + }); + } + ); + } + ); + }); + + // GET /api/chat/tasks/:taskId/unread-count - Получить количество непрочитанных сообщений + router.get('/api/chat/tasks/:taskId/unread-count', requireAuth, (req, res) => { + const { taskId } = req.params; + const userId = req.session.user.id; + + checkTaskAccess(userId, taskId, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(403).json({ error: 'Нет доступа к задаче' }); + } + + db.get(` + SELECT COUNT(*) as unread_count + FROM task_chat_messages m + LEFT JOIN task_chat_reads r ON m.id = r.message_id AND r.user_id = ? + WHERE m.task_id = ? + AND m.user_id != ? + AND m.is_deleted = 0 + AND r.id IS NULL + `, [userId, taskId, userId], (err, result) => { + if (err) { + console.error('❌ Ошибка подсчета непрочитанных:', err); + return res.status(500).json({ error: 'Ошибка подсчета' }); + } + + res.json({ unread_count: result?.unread_count || 0 }); + }); + }); + }); + + // POST /api/chat/tasks/:taskId/mark-read - Отметить все сообщения как прочитанные + router.post('/api/chat/tasks/:taskId/mark-read', requireAuth, (req, res) => { + const { taskId } = req.params; + const userId = req.session.user.id; + + checkTaskAccess(userId, taskId, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(403).json({ error: 'Нет доступа к задаче' }); + } + + db.run(` + INSERT OR IGNORE INTO task_chat_reads (message_id, user_id) + SELECT id, ? FROM task_chat_messages + WHERE task_id = ? AND user_id != ? + `, [userId, taskId, userId], function(err) { + if (err) { + console.error('❌ Ошибка отметки прочитанных:', err); + return res.status(500).json({ error: 'Ошибка отметки' }); + } + + res.json({ + success: true, + marked_count: this.changes + }); + }); + }); + }); + + // GET /api/chat/files/:fileId/download - Скачать файл из чата + router.get('/api/chat/files/:fileId/download', requireAuth, (req, res) => { + const { fileId } = req.params; + const userId = req.session.user.id; + + db.get(` + SELECT f.*, m.task_id + FROM task_chat_files f + JOIN task_chat_messages m ON f.message_id = m.id + WHERE f.id = ? + `, [fileId], (err, file) => { + if (err || !file) { + return res.status(404).json({ error: 'Файл не найден' }); + } + + checkTaskAccess(userId, file.task_id, (err, hasAccess) => { + if (err || !hasAccess) { + return res.status(403).json({ error: 'Нет доступа к файлу' }); + } + + if (!fs.existsSync(file.file_path)) { + return res.status(404).json({ error: 'Файл не найден на сервере' }); + } + + const encodedFileName = encodeURIComponent(file.original_name); + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodedFileName}`); + res.setHeader('Content-Type', file.file_type || 'application/octet-stream'); + + res.sendFile(file.file_path); + }); + }); + }); + + // Вспомогательная функция для уведомлений участников + function notifyTaskParticipants(taskId, senderId, message) { + db.all(` + SELECT DISTINCT user_id + FROM task_assignments + WHERE task_id = ? AND user_id != ? + UNION + SELECT created_by as user_id + FROM tasks + WHERE id = ? AND created_by != ? + `, [taskId, senderId, taskId, senderId], (err, participants) => { + if (err || !participants || participants.length === 0) return; + + // Пытаемся импортировать функцию уведомлений + try { + const { sendTaskNotifications } = require('./notifications'); + participants.forEach(p => { + try { + sendTaskNotifications( + 'chat_message', + taskId, + 'Новое сообщение в чате', + message.message.substring(0, 100), + senderId, + '', + 'chat', + message.user_name, + p.user_id + ); + } catch (notifyErr) { + console.error('Ошибка отправки уведомления:', notifyErr); + } + }); + } catch (importErr) { + console.log('Модуль уведомлений не загружен'); + } + }); + } + + // Подключаем роутер + app.use(router); + console.log('✅ API для чата задач подключено'); +}; \ No newline at end of file diff --git a/database.js b/database.js index c81042d..dea96c6 100644 --- a/database.js +++ b/database.js @@ -500,7 +500,45 @@ function createSQLiteTables() { )`); console.log('✅ Таблицы для типов документов и документов (расширенные задачи) созданы'); +// Таблица для сообщений чата задач +db.run(`CREATE TABLE IF NOT EXISTS task_chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + message TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_edited BOOLEAN DEFAULT false, + is_deleted BOOLEAN DEFAULT false, + reply_to_id INTEGER, + FOREIGN KEY (task_id) REFERENCES tasks (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + FOREIGN KEY (reply_to_id) REFERENCES task_chat_messages (id) ON DELETE SET NULL +)`); +// Таблица для файлов в сообщениях +db.run(`CREATE TABLE IF NOT EXISTS task_chat_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL, + file_path TEXT NOT NULL, + original_name TEXT NOT NULL, + file_size INTEGER NOT NULL, + file_type TEXT, + uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (message_id) REFERENCES task_chat_messages (id) ON DELETE CASCADE +)`); + +// Таблица для прочитанных сообщений +db.run(`CREATE TABLE IF NOT EXISTS task_chat_reads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + read_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(message_id, user_id), + FOREIGN KEY (message_id) REFERENCES task_chat_messages (id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +)`); + console.log('✅ Таблица для сообщений чата задач созданы'); // Создаем индексы для улучшения производительности createSQLiteIndexes(); @@ -623,7 +661,13 @@ function createSQLiteIndexes() { // Индексы для простых документов "CREATE INDEX IF NOT EXISTS idx_simple_documents_task_id ON simple_documents(task_id)", - "CREATE INDEX IF NOT EXISTS idx_simple_documents_document_number ON simple_documents(document_number)" + "CREATE INDEX IF NOT EXISTS idx_simple_documents_document_number ON simple_documents(document_number)", + // Индексы для оптимизации + "CREATE INDEX IF NOT EXISTS idx_task_chat_messages_task_id ON task_chat_messages(task_id)", + "CREATE INDEX IF NOT EXISTS idx_task_chat_messages_created_at ON task_chat_messages(created_at)", + "CREATE INDEX IF NOT EXISTS idx_task_chat_files_message_id ON task_chat_files(message_id)", + "CREATE INDEX IF NOT EXISTS idx_task_chat_reads_message_id ON task_chat_reads(message_id)", + "CREATE INDEX IF NOT EXISTS idx_task_chat_reads_user_id ON task_chat_reads(user_id)" ]; indexes.forEach(indexQuery => { diff --git a/public/chat-ui.js b/public/chat-ui.js new file mode 100644 index 0000000..b1ed7a4 --- /dev/null +++ b/public/chat-ui.js @@ -0,0 +1,817 @@ +// chat-ui.js - Клиентская часть для чата задач + +class TaskChat { + constructor(taskId, taskTitle) { + this.taskId = taskId; + this.taskTitle = taskTitle; + this.messages = []; + this.currentUserId = null; + this.isLoading = false; + this.hasMore = true; + this.lastMessageDate = null; + this.replyToMessage = null; + this.autoRefreshInterval = null; + + this.init(); + } + + async init() { + await this.loadCurrentUser(); + this.createChatModal(); + this.loadMessages(); + this.setupAutoRefresh(); + this.setupEventListeners(); + } + + async loadCurrentUser() { + try { + const response = await fetch('/api/user'); + const data = await response.json(); + this.currentUserId = data.user.id; + } catch (error) { + console.error('Ошибка загрузки пользователя:', error); + } + } + + createChatModal() { + const modalHtml = ` +