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 = ` + + `; + + // Добавляем стили для чата + this.addChatStyles(); + + // Добавляем модальное окно в DOM + const modalContainer = document.createElement('div'); + modalContainer.innerHTML = modalHtml; + document.body.appendChild(modalContainer); + + // Показываем модальное окно + setTimeout(() => { + const modal = document.getElementById(`task-chat-modal-${this.taskId}`); + modal.style.display = 'block'; + + // Фокус на поле ввода + document.getElementById(`chat-input-${this.taskId}`).focus(); + + // Настройка авто-изменения высоты textarea + this.setupTextareaAutoResize(); + }, 10); + } + + addChatStyles() { + if (document.getElementById('chat-styles')) return; + + const styles = ` + + `; + + document.head.insertAdjacentHTML('beforeend', styles); + } + + setupTextareaAutoResize() { + const textarea = document.getElementById(`chat-input-${this.taskId}`); + textarea.addEventListener('input', function() { + this.style.height = 'auto'; + this.style.height = (this.scrollHeight) + 'px'; + }); + } + + setupEventListeners() { + // Отправка по Enter (но не с Shift) + const textarea = document.getElementById(`chat-input-${this.taskId}`); + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + }); + + // Загрузка файлов + const fileInput = document.getElementById(`chat-file-input-${this.taskId}`); + fileInput.addEventListener('change', (e) => { + this.handleFileSelect(e.target.files); + }); + + // Бесконечная прокрутка для загрузки старых сообщений + const messagesContainer = document.getElementById(`chat-messages-${this.taskId}`); + messagesContainer.addEventListener('scroll', () => { + if (messagesContainer.scrollTop === 0 && !this.isLoading && this.hasMore) { + this.loadMoreMessages(); + } + }); + } + + handleFileSelect(files) { + const attachmentsContainer = document.getElementById(`chat-attachments-${this.taskId}`); + attachmentsContainer.innerHTML = ''; + + this.selectedFiles = Array.from(files); + + this.selectedFiles.forEach((file, index) => { + const attachment = document.createElement('div'); + attachment.className = 'chat-attachment'; + attachment.innerHTML = ` + 📎 ${file.name} (${this.formatFileSize(file.size)}) + + `; + attachmentsContainer.appendChild(attachment); + }); + } + + removeAttachment(index) { + if (this.selectedFiles) { + this.selectedFiles.splice(index, 1); + this.handleFileSelect(this.selectedFiles); + } + } + + formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + async loadMessages(before = null) { + if (this.isLoading) return; + + this.isLoading = true; + const loadingEl = document.querySelector(`#chat-messages-list-${this.taskId} .chat-loading`); + if (loadingEl) loadingEl.style.display = 'block'; + + try { + let url = `/api/chat/tasks/${this.taskId}/messages?limit=30`; + if (before) { + url += `&before=${before}`; + } + + const response = await fetch(url); + const data = await response.json(); + + if (data.messages && data.messages.length > 0) { + if (before) { + // Добавляем старые сообщения в начало + this.messages = [...data.messages.reverse(), ...this.messages]; + } else { + // Первая загрузка + this.messages = data.messages.reverse(); + } + this.hasMore = data.hasMore; + this.renderMessages(); + + if (!before) { + // Прокручиваем вниз при первой загрузке + this.scrollToBottom(); + } else { + // Сохраняем позицию прокрутки при загрузке старых сообщений + const container = document.getElementById(`chat-messages-${this.taskId}`); + const oldHeight = container.scrollHeight; + setTimeout(() => { + container.scrollTop = container.scrollHeight - oldHeight; + }, 10); + } + } else { + if (!before) { + this.renderEmpty(); + } + this.hasMore = false; + } + } catch (error) { + console.error('Ошибка загрузки сообщений:', error); + } finally { + this.isLoading = false; + if (loadingEl) loadingEl.style.display = 'none'; + } + } + + async loadMoreMessages() { + if (this.messages.length > 0) { + const oldestMessage = this.messages[0]; + await this.loadMessages(oldestMessage.created_at); + } + } + + renderMessages() { + const messagesList = document.getElementById(`chat-messages-list-${this.taskId}`); + messagesList.innerHTML = ''; + + if (this.messages.length === 0) { + this.renderEmpty(); + return; + } + + this.messages.forEach(message => { + const messageEl = this.createMessageElement(message); + messagesList.appendChild(messageEl); + }); + } + + createMessageElement(message) { + const isOwn = message.user_id === this.currentUserId; + const div = document.createElement('div'); + div.className = `chat-message ${isOwn ? 'chat-message-own' : 'chat-message-other'}`; + div.dataset.messageId = message.id; + + const time = new Date(message.created_at).toLocaleString('ru-RU', { + hour: '2-digit', + minute: '2-digit', + day: '2-digit', + month: '2-digit' + }); + + let replyHtml = ''; + if (message.reply_to_message) { + replyHtml = ` +
+ ↪ Ответ ${message.reply_to_user_name}: "${message.reply_to_message.substring(0, 30)}${message.reply_to_message.length > 30 ? '...' : ''}" +
+ `; + } + + let filesHtml = ''; + if (message.files && message.files.length > 0) { + filesHtml = '
'; + message.files.forEach(file => { + filesHtml += ` +
+ 📎 ${file.original_name} (${this.formatFileSize(file.file_size)}) +
+ `; + }); + filesHtml += '
'; + } + + let actionsHtml = ''; + if (isOwn || window.currentUserRole === 'admin') { + actionsHtml = ` +
+ ${isOwn ? `` : ''} + + +
+ `; + } + + div.innerHTML = ` + ${replyHtml} +
+ ${message.user_name} + ${time} + ${message.is_edited ? '(ред.)' : ''} + ${actionsHtml} +
+
${this.escapeHtml(message.message)}
+ ${filesHtml} + `; + + return div; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + renderEmpty() { + const messagesList = document.getElementById(`chat-messages-list-${this.taskId}`); + messagesList.innerHTML = ` +
+ 💬 Нет сообщений. Напишите что-нибудь... +
+ `; + } + + async sendMessage() { + const input = document.getElementById(`chat-input-${this.taskId}`); + const message = input.value.trim(); + + if (!message && (!this.selectedFiles || this.selectedFiles.length === 0)) { + return; + } + + const formData = new FormData(); + formData.append('message', message); + + if (this.replyToMessage) { + formData.append('reply_to_id', this.replyToMessage.id); + } + + if (this.selectedFiles && this.selectedFiles.length > 0) { + this.selectedFiles.forEach(file => { + formData.append('files', file); + }); + } + + try { + const response = await fetch(`/api/chat/tasks/${this.taskId}/messages`, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (data.success) { + input.value = ''; + input.style.height = 'auto'; + this.selectedFiles = []; + document.getElementById(`chat-attachments-${this.taskId}`).innerHTML = ''; + this.cancelReply(); + + // Добавляем новое сообщение + this.messages.push(data.message); + this.renderMessages(); + this.scrollToBottom(); + } + } catch (error) { + console.error('Ошибка отправки сообщения:', error); + alert('Ошибка отправки сообщения'); + } + } + + async editMessage(messageId) { + const message = this.messages.find(m => m.id === messageId); + if (!message) return; + + const newText = prompt('Редактировать сообщение:', message.message); + if (newText && newText.trim() !== message.message) { + try { + const response = await fetch(`/api/chat/messages/${messageId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ message: newText.trim() }) + }); + + const data = await response.json(); + + if (data.success) { + message.message = newText.trim(); + message.is_edited = true; + this.renderMessages(); + } + } catch (error) { + console.error('Ошибка редактирования:', error); + alert('Ошибка редактирования сообщения'); + } + } + } + + async deleteMessage(messageId) { + if (!confirm('Удалить это сообщение?')) return; + + try { + const response = await fetch(`/api/chat/messages/${messageId}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (data.success) { + this.messages = this.messages.filter(m => m.id !== messageId); + this.renderMessages(); + } + } catch (error) { + console.error('Ошибка удаления:', error); + alert('Ошибка удаления сообщения'); + } + } + + replyToMessage(messageId, userName, messagePreview) { + this.replyToMessage = { + id: messageId, + userName: userName, + preview: messagePreview + }; + + const replyInfo = document.getElementById(`chat-reply-info-${this.taskId}`); + document.getElementById(`reply-to-text-${this.taskId}`).textContent = + `${userName}: "${messagePreview}${messagePreview.length > 30 ? '...' : ''}"`; + replyInfo.style.display = 'flex'; + + document.getElementById(`chat-input-${this.taskId}`).focus(); + } + + cancelReply() { + this.replyToMessage = null; + document.getElementById(`chat-reply-info-${this.taskId}`).style.display = 'none'; + } + + async downloadFile(fileId) { + window.open(`/api/chat/files/${fileId}/download`, '_blank'); + } + + scrollToBottom() { + const container = document.getElementById(`chat-messages-${this.taskId}`); + setTimeout(() => { + container.scrollTop = container.scrollHeight; + }, 10); + } + + setupAutoRefresh() { + // Обновляем непрочитанные каждые 10 секунд + this.autoRefreshInterval = setInterval(() => { + this.updateUnreadCount(); + }, 10000); + } + + async updateUnreadCount() { + try { + const response = await fetch(`/api/chat/tasks/${this.taskId}/unread-count`); + const data = await response.json(); + + const badge = document.getElementById(`chat-unread-${this.taskId}`); + if (data.unread_count > 0) { + badge.textContent = data.unread_count; + badge.style.display = 'inline'; + } else { + badge.style.display = 'none'; + } + } catch (error) { + console.error('Ошибка обновления непрочитанных:', error); + } + } + + async markAllAsRead() { + try { + await fetch(`/api/chat/tasks/${this.taskId}/mark-read`, { + method: 'POST' + }); + document.getElementById(`chat-unread-${this.taskId}`).style.display = 'none'; + } catch (error) { + console.error('Ошибка отметки прочитанных:', error); + } + } + + refreshMessages() { + this.messages = []; + this.loadMessages(); + this.markAllAsRead(); + } + + close() { + if (this.autoRefreshInterval) { + clearInterval(this.autoRefreshInterval); + } + + const modal = document.getElementById(`task-chat-modal-${this.taskId}`); + if (modal) { + modal.style.display = 'none'; + setTimeout(() => { + modal.parentElement.remove(); + }, 300); + } + } +} + +// Глобальный объект для хранения экземпляров чатов +window.taskChats = window.taskChats || {}; + +// Функция для открытия чата (заменяет существующую openTaskChat) +function openTaskChat(taskId) { + // Находим задачу + const task = window.tasks?.find(t => t.id === taskId); + + // Если уже есть открытый чат для этой задачи, просто показываем его + if (window.taskChats[taskId]) { + const existingModal = document.getElementById(`task-chat-modal-${taskId}`); + if (existingModal) { + existingModal.style.display = 'block'; + window.taskChats[taskId].refreshMessages(); + return; + } + } + + // Создаем новый экземпляр чата + window.taskChats[taskId] = new TaskChat(taskId, task ? task.title : `Задача #${taskId}`); +} + +// Функция для закрытия чата +function closeTaskChat(taskId) { + if (window.taskChats[taskId]) { + window.taskChats[taskId].close(); + delete window.taskChats[taskId]; + } +} + +// Инициализация при загрузке страницы +document.addEventListener('DOMContentLoaded', () => { + // Получаем роль текущего пользователя для прав доступа + fetch('/api/user') + .then(response => response.json()) + .then(data => { + window.currentUserRole = data.user.role; + }) + .catch(error => console.error('Ошибка загрузки пользователя:', error)); +}); \ No newline at end of file diff --git a/public/index.html b/public/index.html index bc248da..6619bf4 100644 --- a/public/index.html +++ b/public/index.html @@ -449,6 +449,7 @@ + \ No newline at end of file diff --git a/server.js b/server.js index 9102940..535afaf 100644 --- a/server.js +++ b/server.js @@ -20,6 +20,8 @@ const userManagementAPI = require('./api-users'); // const api2Groups = require('./api2-groups'); // +const chatAPI = require('./api-chat'); +// const app = express(); const PORT = process.env.PORT || 3000; @@ -1461,7 +1463,9 @@ initializeServer().then(() => { console.log('🔐 LDAP авторизация доступна для пользователей школы'); console.log(`👥 Разрешенные группы: ${process.env.ALLOWED_GROUPS}`); console.log('📢 Система уведомлений активна'); - + // Подключаем API для чата + chatAPI(app, db, upload); + console.log('✅ API для чата задач подключено'); // Запускаем фоновые задачи setInterval(checkOverdueTasks, 60000); setInterval(checkUpcomingDeadlines, 60000); diff --git a/task-endpoints.js b/task-endpoints.js index a60df79..559af81 100644 --- a/task-endpoints.js +++ b/task-endpoints.js @@ -1,7 +1,22 @@ // task-endpoints.js const path = require('path'); const fs = require('fs'); - +// Функция для добавления кнопки чата в HTML задачи +function addChatButtonToTask(taskElement, taskId) { + const chatButton = document.createElement('button'); + chatButton.className = 'task-chat-btn'; + chatButton.innerHTML = '💬 Чат'; + chatButton.onclick = (e) => { + e.stopPropagation(); + openTaskChat(taskId); + }; + + // Добавляем кнопку в шапку задачи или в actions + const header = taskElement.querySelector('.task-header'); + if (header) { + header.appendChild(chatButton); + } +} function getApproverUsers(groupId) { return new Promise((resolve) => { db.all(` diff --git a/test-bd.js b/test-bd.js new file mode 100644 index 0000000..a2f3865 --- /dev/null +++ b/test-bd.js @@ -0,0 +1,603 @@ +// test-bd.js - Универсальная проверка и исправление структуры базы данных +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); +const fs = require('fs'); + +// Цвета для вывода в консоль +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m' +}; + +console.log(`${colors.cyan}🔍 ЗАПУСК ПРОВЕРКИ БАЗЫ ДАННЫХ${colors.reset}`); +console.log('=' .repeat(60)); + +// Путь к базе данных +const dbPath = path.join(__dirname, 'data', 'school_crm.db'); +console.log(`${colors.blue}📁 База данных:${colors.reset} ${dbPath}`); + +// Проверяем существование файла базы данных +if (!fs.existsSync(dbPath)) { + console.log(`${colors.yellow}⚠️ Файл базы данных не найден. Он будет создан при первом запуске.${colors.reset}`); +} else { + const stats = fs.statSync(dbPath); + console.log(`${colors.green}✅ Файл базы данных существует (${(stats.size / 1024).toFixed(2)} KB)${colors.reset}`); +} + +// Подключаемся к базе данных +const db = new sqlite3.Database(dbPath); + +// Определяем ожидаемую структуру всех таблиц +const expectedTables = { + // Основные таблицы пользователей + users: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'login', type: 'TEXT UNIQUE NOT NULL' }, + { name: 'password', type: 'TEXT' }, + { name: 'name', type: 'TEXT NOT NULL' }, + { name: 'email', type: 'TEXT UNIQUE NOT NULL' }, + { name: 'role', type: 'TEXT DEFAULT "teacher"' }, + { name: 'auth_type', type: 'TEXT DEFAULT "local"' }, + { name: 'groups', type: 'TEXT' }, + { name: 'description', type: 'TEXT' }, + { name: 'avatar', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'last_login', type: 'DATETIME' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)', + 'CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)', + 'CREATE INDEX IF NOT EXISTS idx_users_login ON users(login)' + ] + }, + + // Таблица задач + tasks: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'title', type: 'TEXT NOT NULL' }, + { name: 'description', type: 'TEXT' }, + { name: 'status', type: 'TEXT DEFAULT "active"' }, + { name: 'created_by', type: 'INTEGER NOT NULL' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'deleted_at', type: 'DATETIME' }, + { name: 'deleted_by', type: 'INTEGER' }, + { name: 'original_task_id', type: 'INTEGER' }, + { name: 'start_date', type: 'DATETIME' }, + { name: 'due_date', type: 'DATETIME' }, + { name: 'rework_comment', type: 'TEXT' }, + { name: 'closed_at', type: 'DATETIME' }, + { name: 'closed_by', type: 'INTEGER' }, + { name: 'task_type', type: 'TEXT DEFAULT "regular"' }, + { name: 'type', type: 'TEXT' }, + { name: 'approver_group_id', type: 'INTEGER' }, + { name: 'document_id', type: 'INTEGER' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks(created_by)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_task_type ON tasks(task_type)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_closed_at ON tasks(closed_at)', + 'CREATE INDEX IF NOT EXISTS idx_tasks_original_task_id ON tasks(original_task_id)' + ] + }, + + // Назначения задач + task_assignments: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'task_id', type: 'INTEGER NOT NULL' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'status', type: 'TEXT DEFAULT "assigned"' }, + { name: 'start_date', type: 'DATETIME' }, + { name: 'due_date', type: 'DATETIME' }, + { name: 'rework_comment', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_task_assignments_task_id ON task_assignments(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_task_assignments_user_id ON task_assignments(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_task_assignments_status ON task_assignments(status)', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_task_assignments_unique ON task_assignments(task_id, user_id) WHERE status != "deleted"' + ] + }, + + // Файлы задач + task_files: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'task_id', type: 'INTEGER NOT NULL' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'filename', type: 'TEXT NOT NULL' }, + { name: 'original_name', type: 'TEXT NOT NULL' }, + { name: 'file_path', type: 'TEXT NOT NULL' }, + { name: 'file_size', type: 'INTEGER NOT NULL' }, + { name: 'uploaded_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_task_files_task_id ON task_files(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_task_files_user_id ON task_files(user_id)' + ] + }, + + // Логи активности + activity_logs: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'task_id', type: 'INTEGER NOT NULL' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'action', type: 'TEXT NOT NULL' }, + { name: 'details', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_activity_logs_task_id ON activity_logs(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at)' + ] + }, + + // Группы пользователей + user_groups: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'name', type: 'TEXT NOT NULL UNIQUE' }, + { name: 'description', type: 'TEXT' }, + { name: 'color', type: 'TEXT DEFAULT "#3498db"' }, + { name: 'can_approve_documents', type: 'BOOLEAN DEFAULT 0' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_user_groups_name ON user_groups(name)', + 'CREATE INDEX IF NOT EXISTS idx_user_groups_can_approve ON user_groups(can_approve_documents)' + ] + }, + + // Членство в группах + user_group_memberships: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'group_id', type: 'INTEGER NOT NULL' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_user_group_memberships_user_id ON user_group_memberships(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_user_group_memberships_group_id ON user_group_memberships(group_id)', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_user_group_memberships_unique ON user_group_memberships(user_id, group_id)' + ] + }, + + // Типы документов (простые) + simple_document_types: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'name', type: 'TEXT NOT NULL' }, + { name: 'description', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_simple_document_types_name ON simple_document_types(name)' + ] + }, + + // Документы (простые) + simple_documents: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'task_id', type: 'INTEGER NOT NULL' }, + { name: 'document_type_id', type: 'INTEGER' }, + { name: 'document_number', type: 'TEXT' }, + { name: 'document_date', type: 'DATE' }, + { name: 'pages_count', type: 'INTEGER' }, + { name: 'urgency_level', type: 'TEXT CHECK(urgency_level IN ("normal", "urgent", "very_urgent"))' }, + { name: 'comment', type: 'TEXT' }, + { name: 'refusal_reason', type: 'TEXT' } + ], + indexes: [ + '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)' + ] + }, + + // Настройки пользователей + user_settings: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'user_id', type: 'INTEGER UNIQUE NOT NULL' }, + { name: 'email_notifications', type: 'BOOLEAN DEFAULT 1' }, + { name: 'notification_email', type: 'TEXT' }, + { name: 'telegram_notifications', type: 'BOOLEAN DEFAULT 0' }, + { name: 'telegram_chat_id', type: 'TEXT' }, + { name: 'vk_notifications', type: 'BOOLEAN DEFAULT 0' }, + { name: 'vk_user_id', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id)' + ] + }, + + // История уведомлений + notification_history: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'task_id', type: 'INTEGER NOT NULL' }, + { name: 'notification_type', type: 'TEXT NOT NULL' }, + { name: 'last_sent_at', type: 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP' }, + { name: 'created_at', type: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_notification_history_user_id ON notification_history(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_notification_history_task_id ON notification_history(task_id)', + 'CREATE INDEX IF NOT EXISTS idx_notification_history_type ON notification_history(notification_type)', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_history_unique ON notification_history(user_id, task_id, notification_type)' + ] + }, + + // Очередь email + email_queue: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'to_email', type: 'TEXT NOT NULL' }, + { name: 'subject', type: 'TEXT NOT NULL' }, + { name: 'html_content', type: 'TEXT NOT NULL' }, + { name: 'user_id', type: 'INTEGER' }, + { name: 'task_id', type: 'INTEGER' }, + { name: 'notification_type', type: 'TEXT' }, + { name: 'retry_count', type: 'INTEGER DEFAULT 0' }, + { name: 'status', type: 'TEXT DEFAULT "pending"' }, + { name: 'error_message', type: 'TEXT' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_email_queue_status ON email_queue(status, created_at)', + 'CREATE INDEX IF NOT EXISTS idx_email_queue_user_id ON email_queue(user_id)', + 'CREATE INDEX IF NOT EXISTS idx_email_queue_task_id ON email_queue(task_id)' + ] + }, + + // ===== НОВЫЕ ТАБЛИЦЫ ДЛЯ ЧАТА ===== + + // Сообщения чата задач + task_chat_messages: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'task_id', type: 'INTEGER NOT NULL' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'message', type: 'TEXT NOT NULL' }, + { name: 'created_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'updated_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' }, + { name: 'is_edited', type: 'BOOLEAN DEFAULT 0' }, + { name: 'is_deleted', type: 'BOOLEAN DEFAULT 0' }, + { name: 'reply_to_id', type: 'INTEGER' } + ], + foreign_keys: [ + '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' + ], + indexes: [ + '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_user_id ON task_chat_messages(user_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_messages_reply_to ON task_chat_messages(reply_to_id)' + ] + }, + + // Файлы в сообщениях чата + task_chat_files: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'message_id', type: 'INTEGER NOT NULL' }, + { name: 'file_path', type: 'TEXT NOT NULL' }, + { name: 'original_name', type: 'TEXT NOT NULL' }, + { name: 'file_size', type: 'INTEGER NOT NULL' }, + { name: 'file_type', type: 'TEXT' }, + { name: 'uploaded_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + foreign_keys: [ + 'FOREIGN KEY (message_id) REFERENCES task_chat_messages (id) ON DELETE CASCADE' + ], + indexes: [ + 'CREATE INDEX IF NOT EXISTS idx_task_chat_files_message_id ON task_chat_files(message_id)' + ] + }, + + // Прочитанные сообщения + task_chat_reads: { + columns: [ + { name: 'id', type: 'INTEGER PRIMARY KEY AUTOINCREMENT' }, + { name: 'message_id', type: 'INTEGER NOT NULL' }, + { name: 'user_id', type: 'INTEGER NOT NULL' }, + { name: 'read_at', type: 'DATETIME DEFAULT CURRENT_TIMESTAMP' } + ], + foreign_keys: [ + 'FOREIGN KEY (message_id) REFERENCES task_chat_messages (id) ON DELETE CASCADE', + 'FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE' + ], + indexes: [ + '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)', + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_task_chat_reads_unique ON task_chat_reads(message_id, user_id)' + ] + } +}; + +// Функция для получения списка существующих таблиц +function getExistingTables() { + return new Promise((resolve, reject) => { + db.all("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name", [], (err, rows) => { + if (err) { + reject(err); + } else { + resolve(rows.map(row => row.name)); + } + }); + }); +} + +// Функция для получения структуры таблицы +function getTableInfo(tableName) { + return new Promise((resolve, reject) => { + db.all(`PRAGMA table_info(${tableName})`, [], (err, rows) => { + if (err) { + reject(err); + } else { + resolve(rows); + } + }); + }); +} + +// Функция для получения индексов таблицы +function getTableIndexes(tableName) { + return new Promise((resolve, reject) => { + db.all(`PRAGMA index_list(${tableName})`, [], (err, rows) => { + if (err) { + reject(err); + } else { + resolve(rows); + } + }); + }); +} + +// Функция для добавления колонки +function addColumn(tableName, columnName, columnType) { + return new Promise((resolve, reject) => { + console.log(`${colors.yellow} ➕ Добавление колонки: ${columnName} (${columnType})${colors.reset}`); + db.run(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnType}`, function(err) { + if (err) { + console.log(`${colors.red} ❌ Ошибка: ${err.message}${colors.reset}`); + reject(err); + } else { + console.log(`${colors.green} ✅ Колонка добавлена${colors.reset}`); + resolve(); + } + }); + }); +} + +// Функция для создания таблицы +function createTable(tableName, tableDefinition) { + return new Promise((resolve, reject) => { + console.log(`${colors.yellow} 🏗️ Создание таблицы: ${tableName}${colors.reset}`); + + let createSQL = `CREATE TABLE IF NOT EXISTS ${tableName} (\n`; + + // Добавляем колонки + const columnDefs = tableDefinition.columns.map(col => ` ${col.name} ${col.type}`).join(',\n'); + createSQL += columnDefs; + + // Добавляем внешние ключи, если есть + if (tableDefinition.foreign_keys && tableDefinition.foreign_keys.length > 0) { + createSQL += ',\n' + tableDefinition.foreign_keys.map(fk => ` ${fk}`).join(',\n'); + } + + createSQL += '\n)'; + + db.run(createSQL, function(err) { + if (err) { + console.log(`${colors.red} ❌ Ошибка: ${err.message}${colors.reset}`); + reject(err); + } else { + console.log(`${colors.green} ✅ Таблица создана${colors.reset}`); + resolve(); + } + }); + }); +} + +// Функция для создания индекса +function createIndex(indexSQL) { + return new Promise((resolve, reject) => { + db.run(indexSQL, function(err) { + if (err) { + // Игнорируем ошибки создания индексов, так как они могут уже существовать + resolve(); + } else { + resolve(); + } + }); + }); +} + +// Основная функция проверки +async function checkDatabase() { + console.log(`${colors.cyan}🔍 Получение списка существующих таблиц...${colors.reset}`); + + try { + const existingTables = await getExistingTables(); + console.log(`${colors.green}✅ Найдено таблиц: ${existingTables.length}${colors.reset}`); + + // Проверяем наличие всех ожидаемых таблиц + const expectedTableNames = Object.keys(expectedTables); + const missingTables = expectedTableNames.filter(t => !existingTables.includes(t)); + const extraTables = existingTables.filter(t => !expectedTableNames.includes(t) && !t.startsWith('sqlite_')); + + console.log(`\n${colors.cyan}📊 СТАТИСТИКА ТАБЛИЦ:${colors.reset}`); + console.log(` Ожидаемых таблиц: ${expectedTableNames.length}`); + console.log(` Существующих таблиц: ${existingTables.length}`); + console.log(` Отсутствует таблиц: ${missingTables.length}`); + console.log(` Лишних таблиц: ${extraTables.length}`); + + if (extraTables.length > 0) { + console.log(`\n${colors.yellow}⚠️ Лишние таблицы (не требуются, но можно оставить):${colors.reset}`); + extraTables.forEach(t => console.log(` - ${t}`)); + } + + // Проверяем структуру каждой ожидаемой таблицы + console.log(`\n${colors.cyan}🔧 ПРОВЕРКА СТРУКТУРЫ ТАБЛИЦ:${colors.reset}`); + + for (const tableName of expectedTableNames) { + console.log(`\n${colors.magenta}📋 Таблица: ${tableName}${colors.reset}`); + + const tableDef = expectedTables[tableName]; + + if (!existingTables.includes(tableName)) { + // Таблица не существует - создаём + console.log(`${colors.yellow} ⚠️ Таблица не существует${colors.reset}`); + await createTable(tableName, tableDef); + + // Создаём индексы для новой таблицы + if (tableDef.indexes && tableDef.indexes.length > 0) { + console.log(`${colors.yellow} 🔧 Создание индексов...${colors.reset}`); + for (const indexSQL of tableDef.indexes) { + await createIndex(indexSQL); + } + console.log(`${colors.green} ✅ Индексы созданы${colors.reset}`); + } + + continue; + } + + // Таблица существует - проверяем колонки + const columns = await getTableInfo(tableName); + const existingColumnNames = columns.map(c => c.name.toLowerCase()); + + console.log(` 📊 Колонок в БД: ${columns.length}, требуется: ${tableDef.columns.length}`); + + // Проверяем наличие всех необходимых колонок + for (const expectedCol of tableDef.columns) { + const colName = expectedCol.name.toLowerCase(); + + if (!existingColumnNames.includes(colName)) { + console.log(`${colors.yellow} ⚠️ Отсутствует колонка: ${expectedCol.name}${colors.reset}`); + await addColumn(tableName, expectedCol.name, expectedCol.type); + } + } + + // Проверяем типы данных колонок (базовая проверка) + for (const existingCol of columns) { + const expectedCol = tableDef.columns.find(c => c.name.toLowerCase() === existingCol.name.toLowerCase()); + if (expectedCol) { + const expectedType = expectedCol.type.split(' ')[0].toUpperCase(); + const existingType = existingCol.type.toUpperCase(); + + if (!existingType.includes(expectedType) && !expectedType.includes(existingType)) { + console.log(`${colors.yellow} ⚠️ Несоответствие типа: ${existingCol.name} - ожидается ${expectedType}, в БД ${existingType}${colors.reset}`); + console.log(` Ручное изменение типа данных может привести к потере данных. Пропускаем.`); + } + } + } + + // Проверяем индексы + try { + const indexes = await getTableIndexes(tableName); + const existingIndexNames = indexes.map(i => i.name.toLowerCase()); + + if (tableDef.indexes && tableDef.indexes.length > 0) { + console.log(` 🔍 Проверка индексов...`); + + for (const indexSQL of tableDef.indexes) { + // Извлекаем имя индекса из SQL (упрощённо) + const match = indexSQL.match(/INDEX\s+IF NOT EXISTS\s+(\w+)/i) || + indexSQL.match(/INDEX\s+(\w+)/i); + + if (match) { + const indexName = match[1].toLowerCase(); + if (!existingIndexNames.includes(indexName)) { + console.log(`${colors.yellow} ➕ Создание индекса: ${indexName}${colors.reset}`); + await createIndex(indexSQL); + } + } + } + } + } catch (err) { + console.log(`${colors.red} ❌ Ошибка проверки индексов: ${err.message}${colors.reset}`); + } + + // Проверяем внешние ключи (только для SQLite - ограниченная поддержка) + if (tableDef.foreign_keys && tableDef.foreign_keys.length > 0) { + // В SQLite сложно проверить внешние ключи через PRAGMA, просто удостоверимся что таблица создана правильно + console.log(` 🔍 Внешние ключи определены в структуре таблицы`); + } + } + + // Проверяем наличие директорий для файлов + console.log(`\n${colors.cyan}📁 ПРОВЕРКА ДИРЕКТОРИЙ:${colors.reset}`); + + const dirsToCheck = [ + path.join(__dirname, 'data', 'uploads'), + path.join(__dirname, 'data', 'uploads', 'tasks'), + path.join(__dirname, 'data', 'uploads', 'chat'), + path.join(__dirname, 'data', 'logs') + ]; + + dirsToCheck.forEach(dir => { + if (!fs.existsSync(dir)) { + console.log(`${colors.yellow} 📁 Создание директории: ${path.basename(dir)}${colors.reset}`); + fs.mkdirSync(dir, { recursive: true }); + console.log(`${colors.green} ✅ Создано${colors.reset}`); + } else { + console.log(`${colors.green} ✅ Директория существует: ${path.basename(dir)}${colors.reset}`); + } + }); + + // Итоговый отчёт + console.log(`\n${colors.cyan}🏁 ИТОГОВЫЙ ОТЧЁТ:${colors.reset}`); + console.log('=' .repeat(60)); + + // Проверяем все ли таблицы теперь существуют + const finalTables = await getExistingTables(); + const stillMissing = expectedTableNames.filter(t => !finalTables.includes(t)); + + if (stillMissing.length === 0) { + console.log(`${colors.green}✅ Все необходимые таблицы присутствуют в базе данных.${colors.reset}`); + } else { + console.log(`${colors.red}❌ Отсутствуют таблицы: ${stillMissing.join(', ')}${colors.reset}`); + } + + console.log(`\n${colors.green}✨ Проверка базы данных завершена!${colors.reset}`); + console.log('=' .repeat(60)); + + } catch (error) { + console.error(`${colors.red}❌ Критическая ошибка:${colors.reset}`, error); + } finally { + // Закрываем соединение с базой данных + db.close((err) => { + if (err) { + console.error(`${colors.red}❌ Ошибка закрытия БД:${colors.reset}`, err.message); + } else { + console.log(`${colors.green}✅ Соединение с БД закрыто${colors.reset}`); + } + }); + } +} + +// Запускаем проверку +checkDatabase(); \ No newline at end of file