чат
This commit is contained in:
506
api-chat.js
Normal file
506
api-chat.js
Normal file
@@ -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 для чата задач подключено');
|
||||
};
|
||||
46
database.js
46
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 => {
|
||||
|
||||
817
public/chat-ui.js
Normal file
817
public/chat-ui.js
Normal file
@@ -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 = `
|
||||
<div class="modal" id="task-chat-modal-${this.taskId}">
|
||||
<div class="modal-content chat-modal-content">
|
||||
<div class="modal-header chat-header">
|
||||
<div class="chat-header-actions">
|
||||
<span class="chat-unread-badge" id="chat-unread-${this.taskId}" style="display: none;">0</span>
|
||||
<button class="chat-refresh-btn" onclick="window.taskChats[${this.taskId}].refreshMessages()" title="Обновить">🔄</button>
|
||||
<span class="close" onclick="window.taskChats[${this.taskId}].close()">×</span>
|
||||
</div>
|
||||
<h3>
|
||||
<span class="chat-icon">💬</span>
|
||||
Чат задачи №${this.taskId}: "${this.taskTitle}"
|
||||
</h3>
|
||||
<h3>
|
||||
<span class="chat-icon"> </span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="modal-body chat-body" style="padding: 0; display: flex; flex-direction: column; height: 500px;">
|
||||
<div class="chat-messages-container" id="chat-messages-${this.taskId}">
|
||||
<div class="chat-messages" id="chat-messages-list-${this.taskId}">
|
||||
<div class="chat-loading" style="display: none;">Загрузка сообщений...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-reply-info" id="chat-reply-info-${this.taskId}" style="display: none;">
|
||||
<span>Ответ на сообщение <span id="reply-to-text-${this.taskId}"></span></span>
|
||||
<button class="chat-cancel-reply" onclick="window.taskChats[${this.taskId}].cancelReply()">✕</button>
|
||||
</div>
|
||||
<div class="chat-input-area">
|
||||
<textarea
|
||||
class="chat-input"
|
||||
id="chat-input-${this.taskId}"
|
||||
placeholder="Напишите сообщение..."
|
||||
rows="1"
|
||||
></textarea>
|
||||
<div class="chat-attachments" id="chat-attachments-${this.taskId}"></div>
|
||||
<div class="chat-actions">
|
||||
<button class="chat-send-btn" onclick="window.taskChats[${this.taskId}].sendMessage()">➤</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Добавляем стили для чата
|
||||
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 = `
|
||||
<style id="chat-styles">
|
||||
.chat-modal-content {
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background-color: #f8f9fa;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.chat-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.chat-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-unread-badge {
|
||||
background-color: #ff4444;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
padding: 2px 6px;
|
||||
font-size: 12px;
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-refresh-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
padding: 4px 8px; /* Увеличили область клика */
|
||||
border-radius: 4px; /* Скругленные углы при наведении */
|
||||
transition: background-color 0.2s; /* Плавный переход цвета */
|
||||
line-height: 1; /* Фиксируем высоту строки */
|
||||
}
|
||||
|
||||
.chat-refresh-btn:hover {
|
||||
background-color: rgba(0,0,0,0.05); /* Легкий серый фон при наведении */
|
||||
opacity: 1; /* Убираем прозрачность или оставляем как есть */
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-messages-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
max-width: 80%;
|
||||
padding: 10px 15px;
|
||||
border-radius: 15px;
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.chat-message-own {
|
||||
align-self: flex-end;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.chat-message-other {
|
||||
align-self: flex-start;
|
||||
background-color: white;
|
||||
color: #333;
|
||||
border-bottom-left-radius: 5px;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.chat-message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-message-own .chat-message-header {
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.chat-message-other .chat-message-header {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chat-message-author {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chat-message-time {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chat-message-edited {
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.chat-message-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.chat-message-files {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-file {
|
||||
background-color: rgba(0,0,0,0.05);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-message-own .chat-file {
|
||||
background-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.chat-file:hover {
|
||||
background-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.chat-reply-info {
|
||||
padding: 10px 20px;
|
||||
background-color: #e9ecef;
|
||||
border-top: 1px solid #dee2e6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-cancel-reply {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
border-top: 1px solid #dee2e6;
|
||||
padding: 15px 20px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
width: 100%;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 20px;
|
||||
padding: 10px 15px;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.chat-attachments {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-attachment {
|
||||
background-color: #e9ecef;
|
||||
border-radius: 15px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.chat-remove-attachment {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chat-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-attach-btn {
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.chat-send-btn:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.chat-loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.chat-no-messages {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
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)})
|
||||
<button class="chat-remove-attachment" onclick="window.taskChats[${this.taskId}].removeAttachment(${index})">✕</button>
|
||||
`;
|
||||
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 = '<div class="chat-loading" style="display: none;">Загрузка сообщений...</div>';
|
||||
|
||||
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 = `
|
||||
<div class="chat-message-reply">
|
||||
↪ Ответ ${message.reply_to_user_name}: "${message.reply_to_message.substring(0, 30)}${message.reply_to_message.length > 30 ? '...' : ''}"
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let filesHtml = '';
|
||||
if (message.files && message.files.length > 0) {
|
||||
filesHtml = '<div class="chat-message-files">';
|
||||
message.files.forEach(file => {
|
||||
filesHtml += `
|
||||
<div class="chat-file" onclick="window.taskChats[${this.taskId}].downloadFile(${file.id})">
|
||||
📎 ${file.original_name} (${this.formatFileSize(file.file_size)})
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
filesHtml += '</div>';
|
||||
}
|
||||
|
||||
let actionsHtml = '';
|
||||
if (isOwn || window.currentUserRole === 'admin') {
|
||||
actionsHtml = `
|
||||
<div class="chat-message-actions">
|
||||
${isOwn ? `<button onclick="window.taskChats[${this.taskId}].editMessage(${message.id})" title="Редактировать">✎</button>` : ''}
|
||||
<button onclick="window.taskChats[${this.taskId}].deleteMessage(${message.id})" title="Удалить">🗑️</button>
|
||||
<!-- <button onclick="window.taskChats[${this.taskId}].replyToMessage(${message.id}, '${message.user_name.replace(/'/g, "\\'")}', '${message.message.replace(/'/g, "\\'").substring(0, 30)}')" title="Ответить">↩</button> -->
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
div.innerHTML = `
|
||||
${replyHtml}
|
||||
<div class="chat-message-header">
|
||||
<span class="chat-message-author">${message.user_name}</span>
|
||||
<span class="chat-message-time">${time}</span>
|
||||
${message.is_edited ? '<span class="chat-message-edited">(ред.)</span>' : ''}
|
||||
${actionsHtml}
|
||||
</div>
|
||||
<div class="chat-message-text">${this.escapeHtml(message.message)}</div>
|
||||
${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 = `
|
||||
<div class="chat-no-messages">
|
||||
💬 Нет сообщений. Напишите что-нибудь...
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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));
|
||||
});
|
||||
@@ -449,6 +449,7 @@
|
||||
<script src="main.js"></script>
|
||||
<script src="tasks_files.js"></script>
|
||||
<script src="navbar.js"></script>
|
||||
<script src="chat-ui.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -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);
|
||||
|
||||
@@ -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(`
|
||||
|
||||
603
test-bd.js
Normal file
603
test-bd.js
Normal file
@@ -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();
|
||||
Reference in New Issue
Block a user