From 6e21887c3b0a9bd395fe3164de07e4556e39ad15 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 23 Oct 2025 21:44:14 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/chat.js | 62 ++- backend/routes/messages.js | 432 +++++++----------- backend/routes/users.js | 12 +- backend/services/UniversalGuestService.js | 32 +- backend/services/adminLogicService.js | 9 +- backend/services/conversationService.js | 81 +++- backend/services/unifiedMessageProcessor.js | 142 ++++-- backend/services/userDeleteService.js | 8 +- database_schema_check.md | 248 ++++++++++ frontend/src/components/ChatInterface.vue | 50 +- frontend/src/components/ContactTable.vue | 45 +- frontend/src/components/Message.vue | 50 ++ frontend/src/composables/useChat.js | 46 +- frontend/src/services/messagesService.js | 9 + frontend/src/views/HomeView.vue | 6 + .../src/views/contacts/ContactDetailsView.vue | 132 ++++-- shared/permissions.js | 57 ++- 17 files changed, 959 insertions(+), 462 deletions(-) create mode 100644 database_schema_check.md diff --git a/backend/routes/chat.js b/backend/routes/chat.js index c1a28e0..34c4a3a 100644 --- a/backend/routes/chat.js +++ b/backend/routes/chat.js @@ -208,22 +208,23 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re // Получаем информацию о пользователе const users = await encryptedDb.getData('users', { id: userId }, 1); - // ✨ НОВОЕ: Валидация прав через adminLogicService - const adminLogicService = require('../services/adminLogicService'); + // ✨ Используем централизованную проверку прав + const { canSendMessage } = require('/app/shared/permissions'); const sessionUserId = req.session.userId; const targetUserId = userId; - const userAccessLevel = req.session.userAccessLevel || { level: 'user', tokenCount: 0, hasAccess: false }; - const canWrite = adminLogicService.canWriteToConversation({ - userAccessLevel: userAccessLevel, - userId: sessionUserId, - conversationUserId: targetUserId - }); + const userRole = req.session.userAccessLevel?.level || 'user'; - if (!canWrite) { - logger.warn(`[Chat] Пользователь ${sessionUserId} пытался писать в беседу ${targetUserId} без прав`); + // Получаем роль получателя + const recipientUser = users[0]; + const recipientRole = recipientUser.role || 'user'; + + const permissionCheck = canSendMessage(userRole, recipientRole, sessionUserId, targetUserId); + + if (!permissionCheck.canSend) { + logger.warn(`[Chat] Пользователь ${sessionUserId} (${userRole}) пытался писать в беседу ${targetUserId} (${recipientRole}) без прав: ${permissionCheck.errorMessage}`); return res.status(403).json({ success: false, - error: 'Нет прав для отправки сообщений в эту беседу' + error: permissionCheck.errorMessage || 'Недостаточно прав для отправки сообщений' }); } if (!users || users.length === 0) { @@ -327,10 +328,10 @@ router.get('/history', requireAuth, async (req, res) => { try { // Если нужен только подсчет if (countOnly) { - let countQuery = 'SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = $2'; - let countParams = [userId, 'user_chat']; + let countQuery = 'SELECT COUNT(*) FROM messages WHERE user_id = $1 AND (message_type = $2 OR message_type = $3)'; + let countParams = [userId, 'user_chat', 'public']; if (conversationId) { - countQuery += ' AND conversation_id = $3'; + countQuery += ' AND conversation_id = $4'; countParams.push(conversationId); } const countResult = await db.getQuery()(countQuery, countParams); @@ -338,17 +339,28 @@ router.get('/history', requireAuth, async (req, res) => { return res.json({ success: true, count: totalCount }); } - // Загружаем сообщения через encryptedDb - const whereConditions = { - user_id: userId, - message_type: 'user_chat' // Фильтруем только публичные сообщения - }; - if (conversationId) { - whereConditions.conversation_id = conversationId; - } - - // Изменяем логику: загружаем ПОСЛЕДНИЕ сообщения, а не с offset - const messages = await encryptedDb.getData('messages', whereConditions, limit, 'created_at DESC', 0); + // Загружаем сообщения: ИИ сообщения + публичные сообщения от других пользователей + // Используем SQL запрос для правильной фильтрации + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + + const result = await db.getQuery()( + `SELECT m.id, m.user_id, m.sender_id, m.conversation_id, + decrypt_text(m.sender_type_encrypted, $2) as sender_type, + decrypt_text(m.content_encrypted, $2) as content, + decrypt_text(m.channel_encrypted, $2) as channel, + decrypt_text(m.role_encrypted, $2) as role, + decrypt_text(m.direction_encrypted, $2) as direction, + m.message_type, m.created_at + FROM messages m + WHERE m.user_id = $1 + AND (m.message_type = 'user_chat' OR m.message_type = 'public') + ORDER BY m.created_at DESC + LIMIT $3`, + [userId, encryptionKey, limit] + ); + + const messages = result.rows; // Переворачиваем массив для правильного порядка messages.reverse(); diff --git a/backend/routes/messages.js b/backend/routes/messages.js index e081092..8028d0d 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -39,18 +39,20 @@ router.get('/public', requireAuth, async (req, res) => { // Публичные сообщения видны на главной странице пользователя const targetUserId = userId || currentUserId; + // Если нужен только подсчет if (countOnly) { const countResult = await db.getQuery()( - `SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'public'`, - [targetUserId] + `SELECT COUNT(*) FROM messages WHERE message_type = 'public' + AND ((user_id = $1 AND sender_id = $2) OR (user_id = $2 AND sender_id = $1))`, + [targetUserId, currentUserId] ); const totalCount = parseInt(countResult.rows[0].count, 10); return res.json({ success: true, count: totalCount, total: totalCount }); } const result = await db.getQuery()( - `SELECT m.id, m.user_id, decrypt_text(m.sender_type_encrypted, $2) as sender_type, + `SELECT m.id, m.user_id, m.sender_id, decrypt_text(m.sender_type_encrypted, $2) as sender_type, decrypt_text(m.content_encrypted, $2) as content, decrypt_text(m.channel_encrypted, $2) as channel, decrypt_text(m.role_encrypted, $2) as role, @@ -59,7 +61,8 @@ router.get('/public', requireAuth, async (req, res) => { arm.last_read_at FROM messages m LEFT JOIN admin_read_messages arm ON arm.user_id = m.user_id AND arm.admin_id = $5 - WHERE m.user_id = $1 AND m.message_type = 'public' + WHERE m.message_type = 'public' + AND ((m.user_id = $1 AND m.sender_id = $5) OR (m.user_id = $5 AND m.sender_id = $1)) ORDER BY m.created_at DESC LIMIT $3 OFFSET $4`, [targetUserId, encryptionKey, limit, offset, currentUserId] @@ -67,8 +70,9 @@ router.get('/public', requireAuth, async (req, res) => { // Получаем общее количество для пагинации const countResult = await db.getQuery()( - `SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'public'`, - [targetUserId] + `SELECT COUNT(*) FROM messages WHERE message_type = 'public' + AND ((user_id = $1 AND sender_id = $2) OR (user_id = $2 AND sender_id = $1))`, + [targetUserId, currentUserId] ); const totalCount = parseInt(countResult.rows[0].count, 10); @@ -102,7 +106,7 @@ router.get('/private', requireAuth, async (req, res) => { // Если нужен только подсчет if (countOnly) { const countResult = await db.getQuery()( - `SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'private'`, + `SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'admin_chat'`, [currentUserId] ); const totalCount = parseInt(countResult.rows[0].count, 10); @@ -120,17 +124,17 @@ router.get('/private', requireAuth, async (req, res) => { arm.last_read_at FROM messages m LEFT JOIN admin_read_messages arm ON arm.user_id = m.user_id AND arm.admin_id = $5 - WHERE m.user_id = $1 AND m.message_type = 'private' + WHERE m.user_id = $1 AND m.message_type = 'admin_chat' ORDER BY m.created_at DESC LIMIT $3 OFFSET $4`, [currentUserId, encryptionKey, limit, offset, currentUserId] ); // Получаем общее количество для пагинации - const countResult = await db.getQuery()( - `SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'private'`, - [currentUserId] - ); + const countResult = await db.getQuery()( + `SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'admin_chat'`, + [currentUserId] + ); const totalCount = parseInt(countResult.rows[0].count, 10); res.json({ @@ -209,44 +213,7 @@ router.get('/read-status', async (req, res) => { } }); -// GET /api/conversations?userId=123 -router.get('/conversations', async (req, res) => { - const userId = req.query.userId; - if (!userId) return res.status(400).json({ error: 'userId required' }); - try { - const result = await db.getQuery()( - 'SELECT * FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1', - [userId] - ); - if (result.rows.length === 0) { - return res.status(404).json({ error: 'Conversation not found' }); - } - res.json(result.rows[0]); - } catch (e) { - res.status(500).json({ error: 'DB error', details: e.message }); - } -}); - -// POST /api/conversations - создать беседу для пользователя -router.post('/conversations', async (req, res) => { - const { userId, title } = req.body; - if (!userId) return res.status(400).json({ error: 'userId required' }); - - // Получаем ключ шифрования через унифицированную утилиту - const encryptionUtils = require('../utils/encryptionUtils'); - const encryptionKey = encryptionUtils.getEncryptionKey(); - - try { - const conversationTitle = title || `Чат с пользователем ${userId}`; - const result = await db.getQuery()( - 'INSERT INTO conversations (user_id, title_encrypted, created_at, updated_at) VALUES ($1, encrypt_text($2, $3), NOW(), NOW()) RETURNING *', - [userId, conversationTitle, encryptionKey] - ); - res.json(result.rows[0]); - } catch (e) { - res.status(500).json({ error: 'DB error', details: e.message }); - } -}); +// УДАЛЕНО: Дублирующиеся endpoint'ы перенесены ниже // Массовая рассылка сообщения во все каналы пользователя // Массовая рассылка сообщений @@ -268,7 +235,7 @@ router.post('/broadcast', requireAuth, requirePermission(PERMISSIONS.BROADCAST), }); if (!canBroadcast) { - logger.warn(`[Messages] Пользователь ${req.session.userId} (роль: ${userRole}) пытался сделать broadcast без прав`); + console.warn(`[Messages] Пользователь ${req.session.userId} (роль: ${userRole}) пытался сделать broadcast без прав`); return res.status(403).json({ error: 'Только редакторы (editor) могут делать массовую рассылку' }); @@ -287,15 +254,15 @@ router.post('/broadcast', requireAuth, requirePermission(PERMISSIONS.BROADCAST), const identities = identitiesRes.rows; // --- Найти или создать беседу (conversation) --- let conversationResult = await db.getQuery()( - 'SELECT id, user_id, created_at, updated_at, decrypt_text(title_encrypted, $2) as title FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1', + 'SELECT id, user_id, created_at, updated_at, title FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1', [user_id, encryptionKey] ); let conversation; if (conversationResult.rows.length === 0) { const title = `Чат с пользователем ${user_id}`; const newConv = await db.getQuery()( - 'INSERT INTO conversations (user_id, title_encrypted, created_at, updated_at) VALUES ($1, encrypt_text($2, $3), NOW(), NOW()) RETURNING *', - [user_id, title, encryptionKey] + 'INSERT INTO conversations (user_id, title, created_at, updated_at) VALUES ($1, $2, NOW(), NOW()) RETURNING *', + [user_id, title] ); conversation = newConv.rows[0]; } else { @@ -312,14 +279,14 @@ router.post('/broadcast', requireAuth, requirePermission(PERMISSIONS.BROADCAST), await emailBot.sendEmail(email, 'Новое сообщение', content); // Сохраняем в messages с conversation_id await db.getQuery()( - `INSERT INTO messages (user_id, conversation_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, created_at) - VALUES ($1, $2, encrypt_text($3, $8), encrypt_text($4, $8), encrypt_text($5, $8), encrypt_text($6, $8), encrypt_text($7, $8), $9, NOW())`, - [user_id, conversation.id, 'editor', content, 'email', 'user', 'out', encryptionKey, 'user_chat'] + `INSERT INTO messages (conversation_id, sender_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, user_id, role, direction, created_at) + VALUES ($1, $2, encrypt_text($3, $12), encrypt_text($4, $12), encrypt_text($5, $12), encrypt_text($6, $12), encrypt_text($7, $12), $8, $9, $10, $11, NOW())`, + [conversation.id, req.session.userId, 'editor', content, 'email', 'user', 'out', 'user_chat', user_id, 'user', 'out', encryptionKey] ); results.push({ channel: 'email', status: 'sent' }); sent = true; } else { - logger.warn('[messages.js] Email Bot не инициализирован'); + console.warn('[messages.js] Email Bot не инициализирован'); results.push({ channel: 'email', status: 'error', error: 'Bot not initialized' }); } } catch (err) { @@ -335,14 +302,14 @@ router.post('/broadcast', requireAuth, requirePermission(PERMISSIONS.BROADCAST), const bot = telegramBot.getBot(); await bot.telegram.sendMessage(telegram, content); await db.getQuery()( - `INSERT INTO messages (user_id, conversation_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, created_at) - VALUES ($1, $2, encrypt_text($3, $8), encrypt_text($4, $8), encrypt_text($5, $8), encrypt_text($6, $8), encrypt_text($7, $8), $9, NOW())`, - [user_id, conversation.id, 'editor', content, 'telegram', 'user', 'out', encryptionKey, 'user_chat'] + `INSERT INTO messages (conversation_id, sender_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, user_id, role, direction, created_at) + VALUES ($1, $2, encrypt_text($3, $12), encrypt_text($4, $12), encrypt_text($5, $12), encrypt_text($6, $12), encrypt_text($7, $12), $8, $9, $10, $11, NOW())`, + [conversation.id, req.session.userId, 'editor', content, 'telegram', 'user', 'out', 'user_chat', user_id, 'user', 'out', encryptionKey] ); results.push({ channel: 'telegram', status: 'sent' }); sent = true; } else { - logger.warn('[messages.js] Telegram Bot не инициализирован'); + console.warn('[messages.js] Telegram Bot не инициализирован'); results.push({ channel: 'telegram', status: 'error', error: 'Bot not initialized' }); } } catch (err) { @@ -354,9 +321,9 @@ router.post('/broadcast', requireAuth, requirePermission(PERMISSIONS.BROADCAST), if (wallet) { // Здесь можно реализовать отправку через web3, если нужно await db.getQuery()( - `INSERT INTO messages (user_id, conversation_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, created_at) - VALUES ($1, $2, encrypt_text($3, $9), encrypt_text($4, $9), encrypt_text($5, $9), encrypt_text($6, $9), encrypt_text($7, $9), $8, NOW())`, - [user_id, conversation.id, 'editor', content, 'wallet', 'user', 'out', 'user_chat', encryptionKey] + `INSERT INTO messages (conversation_id, sender_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, user_id, role, direction, created_at) + VALUES ($1, $2, encrypt_text($3, $12), encrypt_text($4, $12), encrypt_text($5, $12), encrypt_text($6, $12), encrypt_text($7, $12), $8, $9, $10, $11, NOW())`, + [conversation.id, req.session.userId, 'editor', content, 'wallet', 'user', 'out', 'user_chat', user_id, 'user', 'out', encryptionKey] ); results.push({ channel: 'wallet', status: 'saved' }); sent = true; @@ -382,68 +349,78 @@ router.post('/send', requireAuth, async (req, res) => { return res.status(400).json({ error: 'messageType должен быть "public" или "private"' }); } + // Определяем recipientId в зависимости от типа сообщения + let recipientIdNum; + if (messageType === 'private') { + // Приватные сообщения всегда идут к редактору (ID = 1) + recipientIdNum = 1; + } else { + // Конвертируем recipientId в число для публичных сообщений + recipientIdNum = parseInt(recipientId); + if (isNaN(recipientIdNum)) { + return res.status(400).json({ error: 'recipientId должен быть числом' }); + } + } + try { - // Получаем информацию об отправителе const senderId = req.user.id; - const senderRole = req.user.contact_type || req.user.role; + const senderRole = req.user.role || req.user.userAccessLevel?.level || 'user'; + + console.log('[DEBUG] /messages/send: senderId:', senderId, 'senderRole:', senderRole); // Получаем информацию о получателе const recipientResult = await db.getQuery()( - 'SELECT id, contact_type FROM users WHERE id = $1', - [recipientId] + 'SELECT id, role FROM users WHERE id = $1', + [recipientIdNum] ); if (recipientResult.rows.length === 0) { return res.status(404).json({ error: 'Получатель не найден' }); } - const recipientRole = recipientResult.rows[0].contact_type; + const recipientRole = recipientResult.rows[0].role; + console.log('[DEBUG] /messages/send: recipientId:', recipientIdNum, 'recipientRole:', recipientRole); - // Проверка прав согласно матрице разрешений - const canSend = ( - // Editor может отправлять всем - (senderRole === 'editor') || - // User и readonly могут отправлять только editor - ((senderRole === 'user' || senderRole === 'readonly') && recipientRole === 'editor') - ); + // Используем централизованную проверку прав + const { canSendMessage } = require('/app/shared/permissions'); + const permissionCheck = canSendMessage(senderRole, recipientRole, senderId, recipientIdNum); - if (!canSend) { + console.log('[DEBUG] /messages/send: canSend:', permissionCheck.canSend, 'senderRole:', senderRole, 'recipientRole:', recipientRole, 'error:', permissionCheck.errorMessage); + + if (!permissionCheck.canSend) { return res.status(403).json({ - error: 'Недостаточно прав для отправки сообщения этому получателю' + error: permissionCheck.errorMessage || 'Недостаточно прав для отправки сообщения этому получателю' }); } - // Получаем ключ шифрования - const encryptionUtils = require('../utils/encryptionUtils'); - const encryptionKey = encryptionUtils.getEncryptionKey(); + // ✨ Используем unifiedMessageProcessor для унификации + const unifiedMessageProcessor = require('../services/unifiedMessageProcessor'); + const identityService = require('../services/identity-service'); - // Находим или создаем беседу - let conversationResult = await db.getQuery()( - 'SELECT id FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC LIMIT 1', - [recipientId] - ); - - let conversationId; - if (conversationResult.rows.length === 0) { - const title = `Чат с пользователем ${recipientId}`; - const newConv = await db.getQuery()( - 'INSERT INTO conversations (user_id, title_encrypted, created_at, updated_at) VALUES ($1, encrypt_text($2, $3), NOW(), NOW()) RETURNING id', - [recipientId, title, encryptionKey] - ); - conversationId = newConv.rows[0].id; - } else { - conversationId = conversationResult.rows[0].id; + // Получаем wallet идентификатор отправителя + const walletIdentity = await identityService.findIdentity(senderId, 'wallet'); + if (!walletIdentity) { + return res.status(403).json({ + error: 'Требуется подключение кошелька' + }); } - // Сохраняем сообщение с типом - const result = await db.getQuery()( - `INSERT INTO messages (user_id, conversation_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, created_at) - VALUES ($1, $2, encrypt_text($3, $8), encrypt_text($4, $8), encrypt_text($5, $8), encrypt_text($6, $8), encrypt_text($7, $8), $9, NOW()) RETURNING *`, - [recipientId, conversationId, 'editor', content, 'web', 'user', 'out', encryptionKey, messageType] - ); + const identifier = `wallet:${walletIdentity.provider_id}`; - // Отправляем обновление через WebSocket - broadcastMessagesUpdate(); + // Обрабатываем через unifiedMessageProcessor + const result = await unifiedMessageProcessor.processMessage({ + identifier: identifier, + content: content, + channel: 'web', + attachments: [], + conversationId: null, // unifiedMessageProcessor сам найдет/создаст беседу + recipientId: recipientIdNum, + userId: senderId, + metadata: { + messageType: messageType, + markAsRead: markAsRead + } + }); // Если нужно отметить как прочитанное if (markAsRead) { @@ -453,7 +430,7 @@ router.post('/send', requireAuth, async (req, res) => { `INSERT INTO admin_read_messages (admin_id, user_id, last_read_at) VALUES ($1, $2, $3) ON CONFLICT (admin_id, user_id) DO UPDATE SET last_read_at = EXCLUDED.last_read_at`, - [senderId, recipientId, lastReadAt] + [senderId, recipientIdNum, lastReadAt] ); } catch (markError) { console.warn('[WARNING] /send mark-read error:', markError); @@ -461,7 +438,7 @@ router.post('/send', requireAuth, async (req, res) => { } } - res.json({ success: true, message: result.rows[0] }); + res.json({ success: true, message: result }); } catch (e) { console.error('[ERROR] /send:', e); res.status(500).json({ error: 'DB error', details: e.message }); @@ -478,121 +455,60 @@ router.post('/private/send', requireAuth, async (req, res) => { } try { - // Получаем информацию об отправителе и получателе - const senderResult = await db.getQuery()( - 'SELECT id, role FROM users WHERE id = $1', - [senderId] - ); + const senderRole = req.user.role || req.user.userAccessLevel?.level || 'user'; + // Получаем информацию о получателе const recipientResult = await db.getQuery()( 'SELECT id, role FROM users WHERE id = $1', [recipientId] ); - if (senderResult.rows.length === 0) { - return res.status(404).json({ error: 'Отправитель не найден' }); - } - if (recipientResult.rows.length === 0) { return res.status(404).json({ error: 'Получатель не найден' }); } - const sender = senderResult.rows[0]; - const recipient = recipientResult.rows[0]; + const recipientRole = recipientResult.rows[0].role; - // Проверяем права: только к админам-редакторам - if (recipient.role !== 'editor') { + // Используем централизованную проверку прав + const { canSendMessage } = require('/app/shared/permissions'); + const permissionCheck = canSendMessage(senderRole, recipientRole, senderId, recipientId); + + if (!permissionCheck.canSend) { return res.status(403).json({ - error: 'Приватные сообщения можно отправлять только админам-редакторам' + error: permissionCheck.errorMessage || 'Недостаточно прав для отправки приватного сообщения' }); } - // Получаем ключ шифрования - const encryptionUtils = require('../utils/encryptionUtils'); - const encryptionKey = encryptionUtils.getEncryptionKey(); + // ✨ Используем unifiedMessageProcessor для унификации + const unifiedMessageProcessor = require('../services/unifiedMessageProcessor'); + const identityService = require('../services/identity-service'); - // Находим или создаем приватную беседу - let conversationResult = await db.getQuery()( - `SELECT id FROM conversations - WHERE user_id = $1 AND conversation_type = 'private' - ORDER BY updated_at DESC LIMIT 1`, - [recipientId] // Беседа принадлежит получателю (админу) - ); - - let conversationId; - if (conversationResult.rows.length === 0) { - // Создаем новую приватную беседу - const title = `Приватный чат с пользователем ${senderId}`; - const newConv = await db.getQuery()( - 'INSERT INTO conversations (user_id, conversation_type, title_encrypted, created_at, updated_at) VALUES ($1, $2, encrypt_text($3, $4), NOW(), NOW()) RETURNING id', - [recipientId, 'private', title, encryptionKey] - ); - conversationId = newConv.rows[0].id; - - // Добавляем участников в conversation_participants - await db.getQuery()( - 'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', - [conversationId, senderId] - ); - await db.getQuery()( - 'INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', - [conversationId, recipientId] - ); - } else { - conversationId = conversationResult.rows[0].id; + // Получаем wallet идентификатор отправителя + const walletIdentity = await identityService.findIdentity(senderId, 'wallet'); + if (!walletIdentity) { + return res.status(403).json({ + error: 'Требуется подключение кошелька' + }); } - // Сохраняем приватное сообщение - const result = await db.getQuery()( - `INSERT INTO messages ( - conversation_id, - sender_id, - user_id, - sender_type_encrypted, - content_encrypted, - channel_encrypted, - role_encrypted, - direction_encrypted, - message_type, - created_at - ) VALUES ( - $1, $2, $3, - encrypt_text($4, $10), - encrypt_text($5, $10), - encrypt_text($6, $10), - encrypt_text($7, $10), - encrypt_text($8, $10), - $9, - NOW() - ) RETURNING id`, - [ - conversationId, - senderId, // sender_id - ID отправителя - recipientId, // user_id - ID получателя - sender.role, // sender_type_encrypted - content, // content_encrypted - 'web', // channel_encrypted - sender.role, // role_encrypted - 'outgoing', // direction_encrypted - 'private', // message_type - encryptionKey - ] - ); + const identifier = `wallet:${walletIdentity.provider_id}`; - // Обновляем время последнего обновления беседы - await db.getQuery()( - 'UPDATE conversations SET updated_at = NOW() WHERE id = $1', - [conversationId] - ); - - // Отправляем обновление через WebSocket - const { broadcastMessagesUpdate } = require('../wsHub'); - broadcastMessagesUpdate(); + // Обрабатываем через unifiedMessageProcessor + // Для приватных сообщений recipientId всегда = 1 (редактор) + const result = await unifiedMessageProcessor.processMessage({ + identifier: identifier, + content: content, + channel: 'web', + attachments: [], + conversationId: null, // unifiedMessageProcessor сам найдет/создаст беседу + recipientId: 1, // Приватные сообщения всегда к редактору + userId: senderId, + metadata: {} + }); res.json({ success: true, - messageId: result.rows[0].id, - conversationId: conversationId + message: result }); } catch (error) { @@ -615,16 +531,16 @@ router.get('/private/conversations', requireAuth, async (req, res) => { `SELECT DISTINCT c.id as conversation_id, c.user_id, - decrypt_text(c.title_encrypted, $2) as title, + c.title, c.updated_at, COUNT(m.id) as message_count FROM conversations c INNER JOIN conversation_participants cp ON c.id = cp.conversation_id - LEFT JOIN messages m ON c.id = m.conversation_id AND m.message_type = 'private' + LEFT JOIN messages m ON c.id = m.conversation_id AND m.message_type = 'admin_chat' WHERE cp.user_id = $1 AND c.conversation_type = 'private' - GROUP BY c.id, c.user_id, c.title_encrypted, c.updated_at + GROUP BY c.id, c.user_id, c.title, c.updated_at ORDER BY c.updated_at DESC`, - [currentUserId, encryptionKey] + [currentUserId] ); console.log('[DEBUG] /messages/private/conversations result:', result.rows); @@ -640,55 +556,6 @@ router.get('/private/conversations', requireAuth, async (req, res) => { } }); -// GET /api/messages/private/:conversationId - получить историю приватного чата -router.get('/private/:conversationId', requireAuth, async (req, res) => { - const conversationId = req.params.conversationId; - const currentUserId = req.user.id; - - try { - // Проверяем, что пользователь является участником этого чата - const participantCheck = await db.getQuery()( - 'SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2', - [conversationId, currentUserId] - ); - - if (participantCheck.rows.length === 0) { - return res.status(403).json({ error: 'Доступ запрещен' }); - } - - const encryptionUtils = require('../utils/encryptionUtils'); - const encryptionKey = encryptionUtils.getEncryptionKey(); - - // Получаем историю сообщений - const result = await db.getQuery()( - `SELECT - m.id, - m.sender_id, - m.user_id, - decrypt_text(m.sender_type_encrypted, $2) as sender_type, - decrypt_text(m.content_encrypted, $2) as content, - decrypt_text(m.channel_encrypted, $2) as channel, - decrypt_text(m.role_encrypted, $2) as role, - decrypt_text(m.direction_encrypted, $2) as direction, - m.message_type, - m.created_at - FROM messages m - WHERE m.conversation_id = $1 AND m.message_type = 'private' - ORDER BY m.created_at ASC`, - [conversationId, encryptionKey] - ); - - res.json({ - success: true, - messages: result.rows - }); - - } catch (error) { - console.error('[ERROR] /messages/private/:conversationId:', error); - res.status(500).json({ error: 'DB error', details: error.message }); - } -}); - // GET /api/messages/private/unread-count - получить количество непрочитанных приватных сообщений router.get('/private/unread-count', requireAuth, async (req, res) => { const currentUserId = req.user.id; @@ -702,7 +569,7 @@ router.get('/private/unread-count', requireAuth, async (req, res) => { INNER JOIN conversation_participants cp ON c.id = cp.conversation_id WHERE cp.user_id = $1 AND c.conversation_type = 'private' - AND m.message_type = 'private' + AND m.message_type = 'admin_chat' AND m.user_id = $1 -- сообщения адресованные текущему пользователю AND m.sender_id != $1 -- исключаем собственные сообщения AND NOT EXISTS ( @@ -767,6 +634,55 @@ router.post('/private/mark-read', requireAuth, async (req, res) => { } }); +// GET /api/messages/private/:conversationId - получить историю приватного чата +router.get('/private/:conversationId', requireAuth, async (req, res) => { + const conversationId = req.params.conversationId; + const currentUserId = req.user.id; + + try { + // Проверяем, что пользователь является участником этого чата + const participantCheck = await db.getQuery()( + 'SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2', + [conversationId, currentUserId] + ); + + if (participantCheck.rows.length === 0) { + return res.status(403).json({ error: 'Доступ запрещен' }); + } + + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + + // Получаем историю сообщений + const result = await db.getQuery()( + `SELECT + m.id, + m.sender_id, + m.user_id, + decrypt_text(m.sender_type_encrypted, $2) as sender_type, + decrypt_text(m.content_encrypted, $2) as content, + decrypt_text(m.channel_encrypted, $2) as channel, + decrypt_text(m.role_encrypted, $2) as role, + decrypt_text(m.direction_encrypted, $2) as direction, + m.message_type, + m.created_at + FROM messages m + WHERE m.conversation_id = $1 AND m.message_type = 'admin_chat' + ORDER BY m.created_at ASC`, + [conversationId, encryptionKey] + ); + + res.json({ + success: true, + messages: result.rows + }); + + } catch (error) { + console.error('[ERROR] /messages/private/:conversationId:', error); + res.status(500).json({ error: 'DB error', details: error.message }); + } +}); + // GET /api/messages/conversations?userId=123 - получить диалоги пользователя router.get('/conversations', requireAuth, async (req, res) => { const userId = req.query.userId; @@ -794,9 +710,9 @@ router.post('/conversations', requireAuth, async (req, res) => { try { const result = await db.getQuery()( - `INSERT INTO conversations (user_id, title_encrypted, created_at, updated_at) - VALUES ($1, encrypt_text($2, $3), NOW(), NOW()) RETURNING *`, - [userId, title || 'Новый диалог', encryptionKey] + `INSERT INTO conversations (user_id, title, created_at, updated_at) + VALUES ($1, $2, NOW(), NOW()) RETURNING *`, + [userId, title || 'Новый диалог'] ); res.json({ success: true, conversation: result.rows[0] }); } catch (e) { diff --git a/backend/routes/users.js b/backend/routes/users.js index 9e2a446..e5b8b27 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -16,7 +16,7 @@ const db = require('../db'); const logger = require('../utils/logger'); const { requireAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); -const { PERMISSIONS } = require('../shared/permissions'); +const { PERMISSIONS, ROLES } = require('../shared/permissions'); const { deleteUserById } = require('../services/userDeleteService'); const { broadcastContactsUpdate } = require('../wsHub'); // const userService = require('../services/userService'); @@ -95,7 +95,6 @@ router.get('/', requireAuth, async (req, res, next) => { // Фильтрация для USER - видит только editor админов и себя if (userRole === 'user') { - const { ROLES } = require('/app/shared/permissions'); where.push(`(u.role = '${ROLES.EDITOR}' OR u.id = $${idx++})`); params.push(req.user.id); } @@ -375,11 +374,12 @@ router.post('/mark-contact-read', async (req, res) => { if (req.user?.userAccessLevel) { // Используем новую систему ролей - const { ROLES } = require('/app/shared/permissions'); - if (req.user.userAccessLevel.level === ROLES.READONLY) { + if (req.user.userAccessLevel.level === ROLES.READONLY || req.user.userAccessLevel.level === 'readonly') { userRole = ROLES.READONLY; - } else if (req.user.userAccessLevel.level === ROLES.EDITOR) { + } else if (req.user.userAccessLevel.level === ROLES.EDITOR || req.user.userAccessLevel.level === 'editor') { userRole = ROLES.EDITOR; + } else if (req.user.userAccessLevel.level === ROLES.USER || req.user.userAccessLevel.level === 'user') { + userRole = ROLES.USER; } } else if (req.user?.id) { // Fallback для старой системы @@ -760,7 +760,6 @@ router.post('/', async (req, res) => { const encryptionKey = encryptionUtils.getEncryptionKey(); // Используем централизованную систему ролей - const { ROLES } = require('/app/shared/permissions'); try { const result = await db.getQuery()( @@ -824,7 +823,6 @@ router.post('/import', requireAuth, async (req, res) => { } } else { // Создаём нового пользователя с централизованной ролью - const { ROLES } = require('/app/shared/permissions'); const ins = await dbq('INSERT INTO users (first_name_encrypted, last_name_encrypted, role, created_at) VALUES (encrypt_text($1, $4), encrypt_text($2, $4), $3, NOW()) RETURNING id', [first_name, last_name, ROLES.USER, encryptionKey]); userId = ins.rows[0].id; added++; diff --git a/backend/services/UniversalGuestService.js b/backend/services/UniversalGuestService.js index eb70289..10b4839 100644 --- a/backend/services/UniversalGuestService.js +++ b/backend/services/UniversalGuestService.js @@ -449,33 +449,36 @@ class UniversalGuestService { await db.getQuery()( `INSERT INTO messages ( - user_id, conversation_id, + sender_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, - attachment_filename_encrypted, - attachment_mimetype_encrypted, + attachment_filename, + attachment_mimetype, attachment_size, attachment_data, message_type, + user_id, + role, + direction, created_at ) VALUES ( $1, $2, - encrypt_text($3, $14), - encrypt_text($4, $14), - encrypt_text($5, $14), - encrypt_text($6, $14), - encrypt_text($7, $14), - encrypt_text($8, $14), - encrypt_text($9, $14), - $10, $11, $12, $13 + encrypt_text($3, $17), + encrypt_text($4, $17), + encrypt_text($5, $17), + encrypt_text($6, $17), + encrypt_text($7, $17), + $8, $9, $10, $11, + $12, $13, $14, $15, + $16 )`, [ - userId, conversationId, + userId, // sender_id senderType, msg.content, msg.channel, @@ -485,7 +488,10 @@ class UniversalGuestService { msg.attachment_mimetype, msg.attachment_size, msg.attachment_data, - 'public', // message_type для мигрированных сообщений + 'user_chat', // message_type для мигрированных сообщений (личный чат с ИИ) + userId, // user_id + role, // role (незашифрованное) + direction, // direction (незашифрованное) msg.created_at, encryptionKey ] diff --git a/backend/services/adminLogicService.js b/backend/services/adminLogicService.js index 13f8791..d90ff3a 100644 --- a/backend/services/adminLogicService.js +++ b/backend/services/adminLogicService.js @@ -29,13 +29,14 @@ const logger = require('../utils/logger'); function shouldGenerateAiReply(params) { const { senderType, userId, recipientId } = params; - // Обычные пользователи (USER, READONLY) всегда получают AI ответ - if (senderType !== 'editor') { + // Если recipientId не указан или равен userId - это личный чат с ИИ + // ИИ должен отвечать в личных чатах + if (!recipientId || recipientId === userId) { return true; } - // Админы-редакторы (EDITOR) НЕ получают AI ответы - // ни себе, ни другим админам (по спецификации) + // Если recipientId отличается от userId - это публичный чат между пользователями + // ИИ НЕ должен отвечать на сообщения между пользователями return false; } diff --git a/backend/services/conversationService.js b/backend/services/conversationService.js index d8f6973..d174e3b 100644 --- a/backend/services/conversationService.js +++ b/backend/services/conversationService.js @@ -30,12 +30,12 @@ async function getOrCreateConversation(userId, title = 'Новая беседа' // Ищем существующую активную беседу const { rows: existing } = await db.getQuery()( - `SELECT id, user_id, decrypt_text(title_encrypted, $2) as title, created_at, updated_at + `SELECT id, user_id, title, created_at, updated_at FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC LIMIT 1`, - [userId, encryptionKey] + [userId] ); if (existing.length > 0) { @@ -44,10 +44,10 @@ async function getOrCreateConversation(userId, title = 'Новая беседа' // Создаем новую беседу const { rows: newConv } = await db.getQuery()( - `INSERT INTO conversations (user_id, title_encrypted) - VALUES ($1, encrypt_text($2, $3)) - RETURNING id, user_id, decrypt_text(title_encrypted, $3) as title, created_at, updated_at`, - [userId, title, encryptionKey] + `INSERT INTO conversations (user_id, title) + VALUES ($1, $2) + RETURNING id, user_id, title, created_at, updated_at`, + [userId, title] ); logger.info('[ConversationService] Создана новая беседа:', newConv[0].id); @@ -59,6 +59,60 @@ async function getOrCreateConversation(userId, title = 'Новая беседа' } } +/** + * Получить или создать публичную беседу между двумя пользователями + * @param {number} userId1 - ID первого пользователя + * @param {number} userId2 - ID второго пользователя + * @returns {Promise} + */ +async function getOrCreatePublicConversation(userId1, userId2) { + try { + // Ищем существующую публичную беседу между этими пользователями + const { rows: existing } = await db.getQuery()( + `SELECT c.id, c.user_id, c.title, c.created_at, c.updated_at, c.conversation_type + FROM conversations c + INNER JOIN conversation_participants cp1 ON c.id = cp1.conversation_id + INNER JOIN conversation_participants cp2 ON c.id = cp2.conversation_id + WHERE c.conversation_type = 'public_chat' + AND cp1.user_id = $1 AND cp2.user_id = $2 + ORDER BY c.created_at DESC + LIMIT 1`, + [userId1, userId2] + ); + + if (existing.length > 0) { + return existing[0]; + } + + // Создаем новую публичную беседу + const { rows: newConv } = await db.getQuery()( + `INSERT INTO conversations (user_id, title, conversation_type) + VALUES ($1, $2, 'public_chat') + RETURNING id, user_id, title, created_at, updated_at, conversation_type`, + [userId1, `Публичная беседа ${userId1}-${userId2}`] + ); + + const conversation = newConv[0]; + + // Добавляем участников + await db.getQuery()( + `INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`, + [conversation.id, userId1] + ); + await db.getQuery()( + `INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`, + [conversation.id, userId2] + ); + + logger.info('[ConversationService] Создана публичная беседа:', conversation.id); + return conversation; + + } catch (error) { + logger.error('[ConversationService] Ошибка создания публичной беседы:', error); + throw error; + } +} + /** * Получить беседу по ID * @param {number} conversationId - ID беседы @@ -69,10 +123,10 @@ async function getConversationById(conversationId) { const encryptionKey = encryptionUtils.getEncryptionKey(); const { rows } = await db.getQuery()( - `SELECT id, user_id, decrypt_text(title_encrypted, $2) as title, created_at, updated_at + `SELECT id, user_id, title, created_at, updated_at FROM conversations WHERE id = $1`, - [conversationId, encryptionKey] + [conversationId] ); return rows.length > 0 ? rows[0] : null; @@ -93,11 +147,11 @@ async function getUserConversations(userId) { const encryptionKey = encryptionUtils.getEncryptionKey(); const { rows } = await db.getQuery()( - `SELECT id, user_id, decrypt_text(title_encrypted, $2) as title, created_at, updated_at + `SELECT id, user_id, title, created_at, updated_at FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC`, - [userId, encryptionKey] + [userId] ); return rows; @@ -164,10 +218,10 @@ async function updateConversationTitle(conversationId, userId, newTitle) { const { rows } = await db.getQuery()( `UPDATE conversations - SET title_encrypted = encrypt_text($3, $4), updated_at = NOW() + SET title = $3, updated_at = NOW() WHERE id = $1 AND user_id = $2 - RETURNING id, user_id, decrypt_text(title_encrypted, $4) as title, created_at, updated_at`, - [conversationId, userId, newTitle, encryptionKey] + RETURNING id, user_id, title, created_at, updated_at`, + [conversationId, userId, newTitle] ); return rows.length > 0 ? rows[0] : null; @@ -180,6 +234,7 @@ async function updateConversationTitle(conversationId, userId, newTitle) { module.exports = { getOrCreateConversation, + getOrCreatePublicConversation, getConversationById, getUserConversations, touchConversation, diff --git a/backend/services/unifiedMessageProcessor.js b/backend/services/unifiedMessageProcessor.js index 85861be..e28fc20 100644 --- a/backend/services/unifiedMessageProcessor.js +++ b/backend/services/unifiedMessageProcessor.js @@ -18,6 +18,60 @@ const conversationService = require('./conversationService'); const adminLogicService = require('./adminLogicService'); const universalGuestService = require('./UniversalGuestService'); const identityService = require('./identity-service'); + +/** + * Определить тип сообщения по контексту + * @param {number|null} recipientId - ID получателя + * @param {number} userId - ID отправителя + * @param {boolean} isAdminSender - Является ли отправитель админом + * @returns {string} - Тип сообщения: 'user_chat', 'admin_chat', 'public' + */ +function determineMessageType(recipientId, userId, isAdminSender) { + // 1. Личный чат с ИИ (recipientId не указан или равен userId) + if (!recipientId || recipientId === userId) { + return 'user_chat'; + } + + // 2. Приватное сообщение к редактору (recipientId = 1) + if (recipientId === 1) { + return 'admin_chat'; + } + + // 3. Публичное сообщение между пользователями + return 'public'; +} + +/** + * Определить тип беседы + * @param {string} messageType - Тип сообщения + * @param {number|null} recipientId - ID получателя + * @param {number} userId - ID отправителя + * @returns {string} - Тип беседы: 'user_chat', 'private', 'public' + */ +function determineConversationType(messageType, recipientId, userId) { + switch (messageType) { + case 'user_chat': + return 'user_chat'; // Личная беседа с ИИ + case 'admin_chat': + return 'private'; // Приватная беседа с редактором + case 'public': + return 'public_chat'; // Публичная беседа между пользователями + default: + return 'user_chat'; + } +} + +/** + * Определить, нужно ли генерировать AI ответ + * @param {string} messageType - Тип сообщения + * @param {number|null} recipientId - ID получателя + * @param {number} userId - ID отправителя + * @returns {boolean} + */ +function shouldGenerateAiReply(messageType, recipientId, userId) { + // ИИ отвечает только в личных чатах + return messageType === 'user_chat'; +} const { broadcastMessagesUpdate } = require('../wsHub'); // НОВАЯ СИСТЕМА РОЛЕЙ: используем shared/permissions.js const { hasPermission, ROLES, PERMISSIONS } = require('/app/shared/permissions'); @@ -92,24 +146,39 @@ async function processMessage(messageData) { // НОВАЯ СИСТЕМА РОЛЕЙ: определяем права через новую систему const isAdmin = userRole === ROLES.EDITOR || userRole === ROLES.READONLY; - // 4. Определяем нужно ли генерировать AI ответ - const shouldGenerateAi = adminLogicService.shouldGenerateAiReply({ - senderType: isAdmin ? 'editor' : 'user', - userId: userId, - recipientId: recipientId || userId, - channel: channel - }); + // 4. Определяем тип сообщения по контексту + const messageType = determineMessageType(recipientId, userId, isAdmin); + + // 5. Определяем нужно ли генерировать AI ответ + const shouldGenerateAi = shouldGenerateAiReply(messageType, recipientId, userId); logger.info('[UnifiedMessageProcessor] Генерация AI:', { shouldGenerateAi, userRole, isAdmin }); - // 5. Получаем или создаем беседу + // 6. Получаем или создаем беседу с правильным типом let conversation; + const conversationType = determineConversationType(messageType, recipientId, userId); + if (inputConversationId) { conversation = await conversationService.getConversationById(inputConversationId); } if (!conversation) { - conversation = await conversationService.getOrCreateConversation(userId, 'Беседа'); + // Для публичных сообщений создаем беседу между пользователями + if (messageType === 'public') { + conversation = await conversationService.getOrCreatePublicConversation(userId, recipientId); + } else { + // Для личных и админских чатов используем стандартную логику + conversation = await conversationService.getOrCreateConversation(userId, 'Беседа'); + } + + // Обновляем тип беседы в БД, если он не соответствует + if (conversation.conversation_type !== conversationType) { + await db.getQuery()( + 'UPDATE conversations SET conversation_type = $1 WHERE id = $2', + [conversationType, conversation.id] + ); + conversation.conversation_type = conversationType; + } } const conversationId = conversation.id; @@ -133,34 +202,38 @@ async function processMessage(messageData) { const { rows } = await db.getQuery()( `INSERT INTO messages ( - user_id, conversation_id, + sender_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, - attachment_filename_encrypted, - attachment_mimetype_encrypted, + attachment_filename, + attachment_mimetype, attachment_size, attachment_data, message_type, + user_id, + role, + direction, created_at ) VALUES ( $1, $2, - encrypt_text($3, $13), - encrypt_text($4, $13), - encrypt_text($5, $13), - encrypt_text($6, $13), - encrypt_text($7, $13), - encrypt_text($8, $13), - encrypt_text($9, $13), + encrypt_text($3, $16), + encrypt_text($4, $16), + encrypt_text($5, $16), + encrypt_text($6, $16), + encrypt_text($7, $16), + $8, + $9, $10, $11, $12, + $13, $14, $15, NOW() ) RETURNING id`, [ - userId, conversationId, + userId, // sender_id isAdmin ? 'editor' : 'user', content, channel, @@ -170,7 +243,10 @@ async function processMessage(messageData) { attachment_mimetype, attachment_size, attachment_data, - 'user_chat', // message_type + messageType, // message_type + recipientId || userId, // user_id (получатель для публичных сообщений) + 'user', // role (незашифрованное) + 'incoming', // direction (незашифрованное) encryptionKey ] ); @@ -220,34 +296,40 @@ async function processMessage(messageData) { // Сохраняем ответ AI const { rows: aiMessageRows } = await db.getQuery()( `INSERT INTO messages ( - user_id, conversation_id, + sender_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, + user_id, + role, + direction, created_at ) VALUES ( $1, $2, - encrypt_text($3, $9), - encrypt_text($4, $9), - encrypt_text($5, $9), - encrypt_text($6, $9), - encrypt_text($7, $9), - $8, + encrypt_text($3, $12), + encrypt_text($4, $12), + encrypt_text($5, $12), + encrypt_text($6, $12), + encrypt_text($7, $12), + $8, $9, $10, $11, NOW() ) RETURNING id`, [ - userId, conversationId, + userId, // sender_id 'assistant', aiResponse.response, channel, 'assistant', 'outgoing', - 'user_chat', + messageType, + userId, // user_id + 'assistant', // role (незашифрованное) + 'outgoing', // direction (незашифрованное) encryptionKey ] ); diff --git a/backend/services/userDeleteService.js b/backend/services/userDeleteService.js index 7637dd3..d56a186 100644 --- a/backend/services/userDeleteService.js +++ b/backend/services/userDeleteService.js @@ -89,13 +89,7 @@ async function deleteUserById(userId) { ); console.log('[DELETE] Удалено user_tag_links:', resTagLinks.rows.length); - // 9. Удаляем global_read_status - console.log('[DELETE] Начинаем удаление global_read_status для userId:', userId); - const resReadStatus = await db.getQuery()( - 'DELETE FROM global_read_status WHERE user_id = $1 RETURNING user_id', - [userId] - ); - console.log('[DELETE] Удалено global_read_status:', resReadStatus.rows.length); + // 9. global_read_status - таблица не существует, пропускаем // 10. Удаляем самого пользователя console.log('[DELETE] Начинаем удаление пользователя из users:', userId); diff --git a/database_schema_check.md b/database_schema_check.md new file mode 100644 index 0000000..7e5bef9 --- /dev/null +++ b/database_schema_check.md @@ -0,0 +1,248 @@ +# Проверка схемы базы данных + +## Таблица `users` +```sql +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255), + email VARCHAR(255) UNIQUE, + address VARCHAR(255) UNIQUE, + first_name_encrypted TEXT, + last_name_encrypted TEXT, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + role user_role DEFAULT 'user', + first_name VARCHAR(255), + last_name VARCHAR(255), + preferred_language JSONB, + is_blocked BOOLEAN DEFAULT false, + blocked_at TIMESTAMP +); +``` + +**Колонки:** +- `id` - SERIAL PRIMARY KEY +- `username` - VARCHAR(255) +- `email` - VARCHAR(255) UNIQUE +- `address` - VARCHAR(255) UNIQUE +- `first_name_encrypted` - TEXT (зашифрованное) +- `last_name_encrypted` - TEXT (зашифрованное) +- `status` - VARCHAR(50) DEFAULT 'active' +- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP +- `updated_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP +- `role` - user_role DEFAULT 'user' +- `first_name` - VARCHAR(255) (незашифрованное) +- `last_name` - VARCHAR(255) (незашифрованное) +- `preferred_language` - JSONB +- `is_blocked` - BOOLEAN DEFAULT false +- `blocked_at` - TIMESTAMP + +## Таблица `conversations` +```sql +CREATE TABLE conversations ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + conversation_type VARCHAR(50) DEFAULT 'user_chat' +); +``` + +**Колонки:** +- `id` - SERIAL PRIMARY KEY +- `user_id` - INTEGER REFERENCES users(id) +- `title` - VARCHAR(255) (НЕ зашифрованное!) +- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP +- `updated_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP +- `conversation_type` - VARCHAR(50) DEFAULT 'user_chat' + +## Таблица `messages` +```sql +CREATE TABLE messages ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER REFERENCES conversations(id) ON DELETE CASCADE, + sender_type_encrypted TEXT NOT NULL, + sender_id INTEGER, + content_encrypted TEXT, + channel_encrypted TEXT NOT NULL, + role_encrypted TEXT NOT NULL DEFAULT 'user', + direction_encrypted TEXT, + metadata JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + tokens_used INTEGER DEFAULT 0, + is_processed BOOLEAN DEFAULT false, + role VARCHAR(20) NOT NULL DEFAULT 'user', + attachment_filename TEXT, + attachment_mimetype TEXT, + attachment_size BIGINT, + attachment_data BYTEA, + direction VARCHAR(8), + message_type VARCHAR(20) DEFAULT 'public' +); +``` + +**Колонки:** +- `id` - SERIAL PRIMARY KEY +- `conversation_id` - INTEGER REFERENCES conversations(id) +- `sender_type_encrypted` - TEXT NOT NULL (зашифрованное) +- `sender_id` - INTEGER +- `content_encrypted` - TEXT (зашифрованное) +- `channel_encrypted` - TEXT NOT NULL (зашифрованное) +- `role_encrypted` - TEXT NOT NULL DEFAULT 'user' (зашифрованное) +- `direction_encrypted` - TEXT (зашифрованное) +- `metadata` - JSONB +- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP +- `user_id` - INTEGER REFERENCES users(id) +- `tokens_used` - INTEGER DEFAULT 0 +- `is_processed` - BOOLEAN DEFAULT false +- `role` - VARCHAR(20) NOT NULL DEFAULT 'user' (НЕ зашифрованное!) +- `attachment_filename` - TEXT +- `attachment_mimetype` - TEXT +- `attachment_size` - BIGINT +- `attachment_data` - BYTEA +- `direction` - VARCHAR(8) (НЕ зашифрованное!) +- `message_type` - VARCHAR(20) DEFAULT 'public' + +**Триггеры:** +- `trg_set_message_user_id` - автоматически устанавливает user_id + +## Таблица `conversation_participants` +```sql +CREATE TABLE conversation_participants ( + id SERIAL PRIMARY KEY, + conversation_id INTEGER REFERENCES conversations(id) ON DELETE CASCADE, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(conversation_id, user_id) +); +``` + +**Колонки:** +- `id` - SERIAL PRIMARY KEY +- `conversation_id` - INTEGER REFERENCES conversations(id) +- `user_id` - INTEGER REFERENCES users(id) +- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP + +**Индексы:** +- PRIMARY KEY на `id` +- UNIQUE CONSTRAINT на `(conversation_id, user_id)` +- Индекс на `conversation_id` +- Индекс на `user_id` + +## Таблица `admin_read_messages` +```sql +CREATE TABLE admin_read_messages ( + admin_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + last_read_at TIMESTAMP NOT NULL, + PRIMARY KEY (admin_id, user_id) +); +``` + +**Колонки:** +- `admin_id` - INTEGER NOT NULL REFERENCES users(id) (админ) +- `user_id` - INTEGER NOT NULL REFERENCES users(id) (пользователь) +- `last_read_at` - TIMESTAMP NOT NULL (время последнего прочтения) + +**Индексы:** +- PRIMARY KEY на `(admin_id, user_id)` + +## Таблица `admin_read_contacts` +```sql +CREATE TABLE admin_read_contacts ( + admin_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + contact_id TEXT NOT NULL, + read_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (admin_id, contact_id) +); +``` + +**Колонки:** +- `admin_id` - INTEGER NOT NULL REFERENCES users(id) (админ) +- `contact_id` - TEXT NOT NULL (ID контакта) +- `read_at` - TIMESTAMP NOT NULL DEFAULT NOW() (время прочтения) + +**Индексы:** +- PRIMARY KEY на `(admin_id, contact_id)` +- Индекс на `admin_id` +- Индекс на `contact_id` + +## Таблица `user_identities` +```sql +CREATE TABLE user_identities ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + provider_encrypted TEXT NOT NULL, + provider_id_encrypted TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +**Колонки:** +- `id` - SERIAL PRIMARY KEY +- `user_id` - INTEGER REFERENCES users(id) (пользователь) +- `provider_encrypted` - TEXT NOT NULL (зашифрованный провайдер) +- `provider_id_encrypted` - TEXT NOT NULL (зашифрованный ID провайдера) +- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP + +**Индексы:** +- PRIMARY KEY на `id` +- Индекс на `user_id` + +## Таблица `user_preferences` +```sql +CREATE TABLE user_preferences ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + preference_key VARCHAR(50) NOT NULL, + preference_value TEXT, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW(), + UNIQUE(user_id, preference_key) +); +``` + +**Колонки:** +- `id` - SERIAL PRIMARY KEY +- `user_id` - INTEGER NOT NULL REFERENCES users(id) (пользователь) +- `preference_key` - VARCHAR(50) NOT NULL (ключ настройки) +- `preference_value` - TEXT (значение настройки) +- `metadata` - JSONB DEFAULT '{}' (метаданные) +- `created_at` - TIMESTAMP NOT NULL DEFAULT NOW() +- `updated_at` - TIMESTAMP NOT NULL DEFAULT NOW() + +**Индексы:** +- PRIMARY KEY на `id` +- Индекс на `user_id` +- UNIQUE CONSTRAINT на `(user_id, preference_key)` + +## Таблица `user_tag_links` +```sql +CREATE TABLE user_tag_links ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES user_rows(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id, tag_id) +); +``` + +**Колонки:** +- `id` - SERIAL PRIMARY KEY +- `user_id` - INTEGER NOT NULL REFERENCES users(id) (пользователь) +- `tag_id` - INTEGER NOT NULL REFERENCES user_rows(id) (тег) +- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP + +**Индексы:** +- PRIMARY KEY на `id` +- Индекс на `user_id` +- Индекс на `tag_id` +- UNIQUE CONSTRAINT на `(user_id, tag_id)` + +## Анализ проблем в коде + +Теперь, имея полную схему базы данных, давайте проверим код на соответствие: diff --git a/frontend/src/components/ChatInterface.vue b/frontend/src/components/ChatInterface.vue index 87a5a9b..2ca9531 100644 --- a/frontend/src/components/ChatInterface.vue +++ b/frontend/src/components/ChatInterface.vue @@ -522,10 +522,11 @@ async function handleAiReply() { .chat-container { display: flex; flex-direction: column; - height: 100vh; - max-height: 100vh; + height: 100%; + max-height: 100%; min-height: 0; position: relative; + overflow: hidden; } .chat-messages { @@ -533,6 +534,7 @@ async function handleAiReply() { overflow-y: auto; position: relative; padding-bottom: 8px; + min-height: 0; } .chat-input { @@ -544,49 +546,13 @@ async function handleAiReply() { right: 0; border-radius: 12px 12px 0 0; box-shadow: 0 -2px 8px rgba(0,0,0,0.04); -} - -.chat-container { - flex: 1; - display: flex; - flex-direction: column; - margin: 0; - padding: 0; - min-height: 500px; - width: 100%; - position: relative; - background: transparent; - height: 100%; -} - -.chat-messages { - display: flex; - flex-direction: column; - overflow-y: auto; - padding: var(--spacing-lg); - background: transparent; - border-radius: 0; - border: none; - flex: 1; - min-height: 0; -} - -.chat-input { - display: flex; - flex-direction: column; - padding: var(--spacing-sm) var(--spacing-md); - background: var(--color-white); - border-radius: 0; - border: none; - border-top: 1px solid #e9ecef; flex-shrink: 0; - transition: all var(--transition-normal); - z-index: 10; - box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05); - position: sticky; - bottom: 0; + min-height: 80px; } + + + .chat-input textarea { width: 100%; border: none; diff --git a/frontend/src/components/ContactTable.vue b/frontend/src/components/ContactTable.vue index 412a0f4..33a3fa6 100644 --- a/frontend/src/components/ContactTable.vue +++ b/frontend/src/components/ContactTable.vue @@ -82,18 +82,19 @@ + ID Тип Имя Email Telegram Кошелек Дата создания - Действие - - + + + {{ contact.id }} Неизвестно {{ contact.name || '-' }} - {{ contact.email || '-' }} - {{ contact.telegram || '-' }} - {{ contact.wallet || '-' }} + {{ maskPersonalData(contact.email) }} + {{ maskPersonalData(contact.telegram) }} + {{ maskPersonalData(contact.wallet) }} {{ contact.created_at ? new Date(contact.created_at).toLocaleString() : '-' }} - - ✉️ - - @@ -132,6 +129,7 @@ import { useTagsWebSocket } from '../composables/useTagsWebSocket'; import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSocket'; import { usePermissions } from '@/composables/usePermissions'; import { useAuthContext } from '@/composables/useAuth'; +import { PERMISSIONS } from '/app/shared/permissions.js'; import api from '../api/axios'; import { sendMessage, getPrivateUnreadCount } from '../services/messagesService'; import { useRoles } from '@/composables/useRoles'; @@ -147,7 +145,7 @@ const contactsArray = computed(() => props.contacts || []); const newIds = computed(() => props.newContacts.map(c => c.id)); const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id))); const router = useRouter(); -const { canViewContacts, canSendToUsers, canDeleteData, canDeleteMessages, canManageSettings, canChatWithAdmins, canEditData } = usePermissions(); +const { canViewContacts, canSendToUsers, canDeleteData, canDeleteMessages, canManageSettings, canChatWithAdmins, canEditData, hasPermission } = usePermissions(); const { userAccessLevel, userId, isAuthenticated } = useAuthContext(); const { roles, getRoleDisplayName, getRoleClass, fetchRoles, clearRoles } = useRoles(); @@ -175,6 +173,19 @@ async function loadPrivateUnreadCount() { } } +// Функция маскировки персональных данных для читателей +function maskPersonalData(data) { + if (!data || data === '-') return '-'; + + // Если пользователь имеет права редактора, показываем полные данные + if (hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS)) { + return data; + } + + // Для читателей маскируем данные полностью звездочками + return '***'; +} + // Новый фильтр тегов через мультисвязи const availableTags = ref([]); const selectedTagIds = ref([]); @@ -404,14 +415,14 @@ function formatDate(date) { if (!date) return '-'; return new Date(date).toLocaleString(); } -async function showDetails(contact) { +async function goToContactDetails(contactId) { if (props.markContactAsRead) { - await props.markContactAsRead(contact.id); + await props.markContactAsRead(contactId); } if (props.markMessagesAsReadForUser) { - props.markMessagesAsReadForUser(contact.id); + props.markMessagesAsReadForUser(contactId); } - router.push({ name: 'contact-details', params: { id: contact.id } }); + router.push({ name: 'contact-details', params: { id: contactId } }); } function onImported() { @@ -430,7 +441,7 @@ async function openChatForSelected() { if (!contact) return; // Открываем чат с этим контактом (user_chat) - await showDetails(contact); + await goToContactDetails(contact.id); } // Новая функция для отправки публичного сообщения @@ -448,7 +459,7 @@ function sendPublicMessage() { } // Открываем страницу детали контакта с чатом для публичных сообщений - showDetails(contact); + goToContactDetails(contactId); } // Функция для открытия приватного чата diff --git a/frontend/src/components/Message.vue b/frontend/src/components/Message.vue index 4b1a933..b512304 100644 --- a/frontend/src/components/Message.vue +++ b/frontend/src/components/Message.vue @@ -40,6 +40,11 @@
+ + +
@@ -127,6 +132,11 @@ const isCurrentUserMessage = computed(() => { return props.message.sender_id === props.message.user_id; } + // Для публичных сообщений сравниваем sender_id с currentUserId + if (props.message.message_type === 'public' && props.currentUserId) { + return props.message.sender_id == props.currentUserId; + } + // Для обычных сообщений используем стандартную логику return props.message.sender_type === 'user' || props.message.role === 'user'; }); @@ -145,6 +155,22 @@ const formatWalletAddress = (address) => { return address; }; +// --- Логика ссылки "Ответить" для публичных сообщений --- +const shouldShowReplyLink = computed(() => { + // Показываем ссылку только для публичных сообщений от других пользователей + return props.message.message_type === 'public' && + !isCurrentUserMessage.value && + props.message.sender_id && + props.currentUserId && + props.message.sender_id !== props.currentUserId; +}); + +const replyLink = computed(() => { + if (!shouldShowReplyLink.value) return ''; + // Ссылка ведет на страницу контакта отправителя + return `/contacts/${props.message.sender_id}`; +}); + // --- Работа с вложениями --- const attachment = computed(() => { // Ожидаем массив attachments, даже если там только один элемент @@ -554,6 +580,30 @@ function copyEmail(email) { } } +/* Стили для ссылки "Ответить" */ +.message-reply-link { + margin-top: var(--spacing-xs); + text-align: right; +} + +.reply-link { + color: var(--color-primary, #007bff); + text-decoration: none; + font-size: var(--font-size-sm); + font-weight: 500; + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-sm); + background-color: rgba(0, 123, 255, 0.1); + transition: all 0.2s ease; + display: inline-block; +} + +.reply-link:hover { + background-color: rgba(0, 123, 255, 0.2); + color: var(--color-primary-dark, #0056b3); + text-decoration: none; +} + /* Адаптивность для мобильных устройств */ @media (max-width: 768px) { .private-current-user, diff --git a/frontend/src/composables/useChat.js b/frontend/src/composables/useChat.js index 404152f..1f94a2d 100644 --- a/frontend/src/composables/useChat.js +++ b/frontend/src/composables/useChat.js @@ -105,10 +105,16 @@ export function useChat(auth) { let totalMessages = -1; if (initial || messageLoading.value.offset === 0) { try { - const countResponse = await api.get('/messages/public', { params: { count_only: true } }); - if (!countResponse.data.success) throw new Error('Не удалось получить количество сообщений'); - totalMessages = countResponse.data.total || countResponse.data.count || 0; - // console.log(`[useChat] Всего сообщений в истории: ${totalMessages}`); + // Получаем количество личных сообщений с ИИ + const personalCountResponse = await api.get('/chat/history', { params: { count_only: true } }); + const personalCount = personalCountResponse.data.success ? (personalCountResponse.data.total || 0) : 0; + + // Получаем количество публичных сообщений + const publicCountResponse = await api.get('/messages/public', { params: { count_only: true } }); + const publicCount = publicCountResponse.data.success ? (publicCountResponse.data.total || 0) : 0; + + totalMessages = personalCount + publicCount; + // console.log(`[useChat] Всего сообщений в истории: ${totalMessages} (личные: ${personalCount}, публичные: ${publicCount})`); } catch(countError) { // console.error('[useChat] Ошибка получения количества сообщений:', countError); // Не прерываем выполнение, попробуем загрузить без total @@ -122,13 +128,41 @@ export function useChat(auth) { // console.log(`[useChat] Рассчитано начальное смещение: ${effectiveOffset}`); } - // Используем новый API для публичных сообщений с пагинацией - const response = await api.get('/messages/public', { + // Загружаем личные сообщения с ИИ + const personalResponse = await api.get('/chat/history', { params: { offset: effectiveOffset, limit: messageLoading.value.limit } }); + + // Загружаем публичные сообщения от других пользователей + const publicResponse = await api.get('/messages/public', { + params: { + offset: 0, + limit: 50 + } + }); + + // Объединяем сообщения + let allMessages = []; + if (personalResponse.data.success && personalResponse.data.messages) { + allMessages = [...allMessages, ...personalResponse.data.messages]; + } + if (publicResponse.data.success && publicResponse.data.messages) { + allMessages = [...allMessages, ...publicResponse.data.messages]; + } + + // Сортируем по времени создания + allMessages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + + const response = { + data: { + success: true, + messages: allMessages, + total: allMessages.length + } + }; if (response.data.success && response.data.messages) { const loadedMessages = response.data.messages; diff --git a/frontend/src/services/messagesService.js b/frontend/src/services/messagesService.js index c31aa34..60c6826 100644 --- a/frontend/src/services/messagesService.js +++ b/frontend/src/services/messagesService.js @@ -166,4 +166,13 @@ export async function markPrivateMessagesAsRead(conversationId) { conversationId }); return data; +} + +// Функция для загрузки личных сообщений с ИИ +export async function getPersonalChatHistory(options = {}) { + const { limit = 50, offset = 0 } = options; + const { data } = await api.get('/chat/history', { + params: { limit, offset } + }); + return data; } \ No newline at end of file diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 1906bbf..0161c85 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -33,6 +33,7 @@ :messages="messages" :is-loading="isLoading || isConnectingWallet" :has-more-messages="messageLoading.hasMoreMessages" + :currentUserId="auth.userId" v-model:newMessage="newMessage" v-model:attachments="attachments" @send-message="handleSendMessage" @@ -44,6 +45,7 @@ :messages="messages" :is-loading="isLoading || isConnectingWallet" :has-more-messages="messageLoading.hasMoreMessages" + :currentUserId="auth.userId" v-model:newMessage="newMessage" v-model:attachments="attachments" @send-message="handleSendMessage" @@ -161,6 +163,8 @@ background-color: var(--color-white); border-radius: var(--radius-lg); height: calc(100vh - 40px); + display: flex; + flex-direction: column; overflow: hidden; } @@ -171,6 +175,7 @@ margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 2px solid #e9ecef; + flex-shrink: 0; } .header-content h1 { @@ -190,6 +195,7 @@ min-height: 0; display: flex; flex-direction: column; + height: 100%; } /* Адаптивность */ diff --git a/frontend/src/views/contacts/ContactDetailsView.vue b/frontend/src/views/contacts/ContactDetailsView.vue index d692656..c1b0a61 100644 --- a/frontend/src/views/contacts/ContactDetailsView.vue +++ b/frontend/src/views/contacts/ContactDetailsView.vue @@ -21,7 +21,9 @@

Детали контакта

-
+
+
+
ID пользователя: {{ contact.id }}
Имя:
-
Email: {{ contact.email || '-' }}
-
Telegram: {{ contact.telegram || '-' }}
-
Кошелек: {{ contact.wallet || '-' }}
+
Email: {{ maskPersonalData(contact.email) }}
+
Telegram: {{ maskPersonalData(contact.telegram) }}
+
Кошелек: {{ maskPersonalData(contact.wallet) }}
Язык:
@@ -101,6 +103,7 @@
+

Чат с пользователем

@@ -109,9 +112,10 @@ :isLoading="isLoadingMessages" :attachments="chatAttachments" :newMessage="chatNewMessage" - :canSend="canSendToUsers" + :canSend="canSendToUsers && !!address" :canGenerateAI="canGenerateAI" :canSelectMessages="canGenerateAI" + :currentUserId="currentUserId" @send-message="handleSendMessage" @update:newMessage="val => chatNewMessage = val" @update:attachments="val => chatAttachments = val" @@ -160,11 +164,13 @@ import Message from '../../components/Message.vue'; import ChatInterface from '../../components/ChatInterface.vue'; import contactsService from '../../services/contactsService.js'; import messagesService from '../../services/messagesService.js'; -import { getPublicMessages, getConversationByUserId } from '../../services/messagesService.js'; +import { getPublicMessages, getConversationByUserId, sendMessage, getPersonalChatHistory } from '../../services/messagesService.js'; import { useAuthContext } from '@/composables/useAuth'; import { usePermissions } from '@/composables/usePermissions'; +import { PERMISSIONS } from '/app/shared/permissions.js'; import { useContactsAndMessagesWebSocket } from '@/composables/useContactsWebSocket'; -const { canEditContacts, canDeleteData, canManageTags, canBlockUsers, canSendToUsers, canGenerateAI, canViewContacts } = usePermissions(); +const { canEditContacts, canDeleteData, canManageTags, canBlockUsers, canSendToUsers, canGenerateAI, canViewContacts, hasPermission } = usePermissions(); +const { address, userId: currentUserId } = useAuthContext(); const { markContactAsRead } = useContactsAndMessagesWebSocket(); // Подписываемся на централизованные события очистки и обновления данных @@ -214,6 +220,19 @@ const tagsTableId = ref(null); const { onTagsUpdate } = useTagsWebSocket(); let unsubscribeFromTags = null; +// Функция маскировки персональных данных для читателей +function maskPersonalData(data) { + if (!data || data === '-') return '-'; + + // Если пользователь имеет права редактора, показываем полные данные + if (hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS)) { + return data; + } + + // Для читателей маскируем данные полностью звездочками + return '***'; +} + async function ensureTagsTable() { // Получаем все пользовательские таблицы const tables = await tablesService.getTables(); @@ -402,16 +421,42 @@ async function loadMessages() { console.log('[ContactDetailsView] 📥 loadMessages START for:', contact.value.id); isLoadingMessages.value = true; try { - // Загружаем только публичные сообщения этого пользователя с пагинацией - const response = await getPublicMessages(contact.value.id, { limit: 50, offset: 0 }); - console.log('[ContactDetailsView] 📩 Loaded messages:', response.messages?.length || 0, 'for', contact.value.id); + // Проверяем, является ли контакт собственным ID пользователя + const isOwnContact = currentUserId.value && contact.value.id == currentUserId.value; - if (response.success && response.messages) { - messages.value = response.messages; + let allMessages = []; + + if (isOwnContact) { + // Для собственного ID загружаем И личные сообщения с ИИ, И публичные сообщения от других пользователей + console.log('[ContactDetailsView] 🔍 Loading personal chat with AI + public messages for own ID:', contact.value.id); + + // Загружаем личные сообщения с ИИ + const personalResponse = await getPersonalChatHistory({ limit: 50, offset: 0 }); + if (personalResponse.success && personalResponse.messages) { + allMessages = [...allMessages, ...personalResponse.messages]; + } + + // Загружаем публичные сообщения от других пользователей (входящие) + const publicResponse = await getPublicMessages(contact.value.id, { limit: 50, offset: 0 }); + if (publicResponse.success && publicResponse.messages) { + allMessages = [...allMessages, ...publicResponse.messages]; + } + + // Сортируем по времени создания + allMessages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + } else { - messages.value = []; + // Для других пользователей загружаем публичные сообщения между текущим пользователем и выбранным контактом + console.log('[ContactDetailsView] 🔍 Loading public messages between current user and contact:', contact.value.id); + const response = await getPublicMessages(contact.value.id, { limit: 50, offset: 0 }); + if (response.success && response.messages) { + allMessages = response.messages; + } } + console.log('[ContactDetailsView] 📩 Loaded messages:', allMessages.length, 'for', contact.value.id); + messages.value = allMessages; + if (messages.value.length > 0) { lastMessageDate.value = messages.value[messages.value.length - 1].created_at; } else { @@ -487,27 +532,23 @@ async function handleSendMessage({ message, attachments }) { return; } try { - const result = await messagesService.broadcastMessage({ - userId: contact.value.id, - message, - attachments + const result = await sendMessage({ + recipientId: contact.value.id, + content: message, + messageType: 'public' }); - // Формируем текст результата для отображения админу - let resultText = ''; - if (result && Array.isArray(result.results)) { - resultText = 'Результат рассылки по каналам:'; - for (const r of result.results) { - resultText += `\n${r.channel}: ${(r.status === 'sent' || r.status === 'saved') ? 'Успех' : 'Ошибка'}${r.error ? ' (' + r.error + ')' : ''}`; + + if (result && result.success) { + // Очищаем поле ввода после успешной отправки + chatNewMessage.value = ''; + // Обновляем список сообщений + await loadMessages(); + if (typeof ElMessageBox === 'function') { + ElMessageBox.alert('Сообщение отправлено успешно', 'Успех', { type: 'success' }); } } else { - resultText = 'Не удалось получить подробный ответ от сервера.'; + throw new Error(result?.message || 'Неизвестная ошибка'); } - if (typeof ElMessageBox === 'function') { - ElMessageBox.alert(resultText, 'Результат рассылки', { type: 'info' }); - } else { - console.log('Результат рассылки:', resultText); - } - await loadMessages(); } catch (e) { if (typeof ElMessageBox === 'function') { ElMessageBox.alert('Ошибка отправки: ' + (e?.response?.data?.error || e?.message || e), 'Ошибка', { type: 'error' }); @@ -701,23 +742,28 @@ watch(userId, async () => {