diff --git a/backend/routes/chat.js b/backend/routes/chat.js index 193d036..c1a28e0 100644 --- a/backend/routes/chat.js +++ b/backend/routes/chat.js @@ -35,7 +35,7 @@ const upload = multer({ storage: storage }); router.post('/guest-message', upload.array('attachments'), async (req, res) => { try { // Frontend отправляет FormData, поэтому читаем из req.body - const content = req.body.content || req.body.message; + const content = req.body.message; const guestId = req.body.guestId; const files = req.files || []; @@ -173,7 +173,9 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => { // Обработчик для сообщений аутентифицированных пользователей (НОВАЯ ВЕРСИЯ) router.post('/message', requireAuth, upload.array('attachments'), async (req, res) => { try { - const { content, conversationId, recipientId } = req.body; + // Frontend отправляет FormData, поэтому читаем из req.body + const content = req.body.message; + const { conversationId, recipientId } = req.body; const userId = req.session.userId; const files = req.files || []; diff --git a/backend/routes/messages.js b/backend/routes/messages.js index 8b41439..e081092 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -146,249 +146,10 @@ router.get('/private', requireAuth, async (req, res) => { } }); -// GET /api/messages?userId=123 - УСТАРЕВШИЙ эндпоинт, используйте /api/messages/public или /api/messages/private -// Оставлен для обратной совместимости -router.get('/', requireAuth, requirePermission(PERMISSIONS.VIEW_CONTACTS), async (req, res) => { - const userId = req.query.userId; - const conversationId = req.query.conversationId; - // Получаем ключ шифрования через унифицированную утилиту - const encryptionUtils = require('../utils/encryptionUtils'); - const encryptionKey = encryptionUtils.getEncryptionKey(); - try { - // Проверяем, это гостевой идентификатор (формат: guest_123) - if (userId && userId.startsWith('guest_')) { - const guestId = parseInt(userId.replace('guest_', '')); - - if (isNaN(guestId)) { - return res.status(400).json({ error: 'Invalid guest ID format' }); - } - - // Сначала получаем guest_identifier по guestId - const identifierResult = await db.getQuery()( - `WITH decrypted_guest AS ( - SELECT - id, - decrypt_text(identifier_encrypted, $2) as guest_identifier, - channel - FROM unified_guest_messages - WHERE user_id IS NULL - ) - SELECT guest_identifier, channel - FROM decrypted_guest - GROUP BY guest_identifier, channel - HAVING MIN(id) = $1 - LIMIT 1`, - [guestId, encryptionKey] - ); - - if (identifierResult.rows.length === 0) { - return res.json([]); - } - - const guestIdentifier = identifierResult.rows[0].guest_identifier; - const guestChannel = identifierResult.rows[0].channel; - - // Теперь получаем все сообщения этого гостя (по идентификатору И каналу) - const guestResult = await db.getQuery()( - `SELECT - id, - decrypt_text(identifier_encrypted, $3) as user_id, - channel, - decrypt_text(content_encrypted, $3) as content, - content_type, - attachments, - media_metadata, - is_ai, - created_at - FROM unified_guest_messages - WHERE decrypt_text(identifier_encrypted, $3) = $1 - AND channel = $2 - ORDER BY created_at ASC`, - [guestIdentifier, guestChannel, encryptionKey] - ); - - // Преобразуем формат для совместимости с фронтендом - const messages = guestResult.rows.map(msg => ({ - id: msg.id, - user_id: `guest_${guestId}`, - sender_type: msg.is_ai ? 'bot' : 'user', - content: msg.content, - channel: msg.channel, - role: 'guest', - direction: msg.is_ai ? 'incoming' : 'outgoing', - created_at: msg.created_at, - attachment_filename: null, - attachment_mimetype: null, - attachment_size: null, - attachment_data: null, - // Дополнительные поля для медиа - content_type: msg.content_type, - attachments: msg.attachments, - media_metadata: msg.media_metadata - })); - - return res.json(messages); - } - - // Стандартная логика для зарегистрированных пользователей - ТОЛЬКО ПУБЛИЧНЫЕ СООБЩЕНИЯ - let result; - if (conversationId) { - result = await db.getQuery()( - `SELECT id, user_id, decrypt_text(sender_type_encrypted, $2) as sender_type, decrypt_text(content_encrypted, $2) as content, decrypt_text(channel_encrypted, $2) as channel, decrypt_text(role_encrypted, $2) as role, decrypt_text(direction_encrypted, $2) as direction, created_at, decrypt_text(attachment_filename_encrypted, $2) as attachment_filename, decrypt_text(attachment_mimetype_encrypted, $2) as attachment_mimetype, attachment_size, attachment_data, message_type - FROM messages - WHERE conversation_id = $1 AND message_type = 'public' - ORDER BY created_at ASC`, - [conversationId, encryptionKey] - ); - } else if (userId) { - result = await db.getQuery()( - `SELECT id, user_id, decrypt_text(sender_type_encrypted, $2) as sender_type, decrypt_text(content_encrypted, $2) as content, decrypt_text(channel_encrypted, $2) as channel, decrypt_text(role_encrypted, $2) as role, decrypt_text(direction_encrypted, $2) as direction, created_at, decrypt_text(attachment_filename_encrypted, $2) as attachment_filename, decrypt_text(attachment_mimetype_encrypted, $2) as attachment_mimetype, attachment_size, attachment_data, message_type - FROM messages - WHERE user_id = $1 AND message_type = 'public' - ORDER BY created_at ASC`, - [userId, encryptionKey] - ); - } else { - result = await db.getQuery()( - `SELECT id, user_id, decrypt_text(sender_type_encrypted, $1) as sender_type, decrypt_text(content_encrypted, $1) as content, decrypt_text(channel_encrypted, $1) as channel, decrypt_text(role_encrypted, $1) as role, decrypt_text(direction_encrypted, $1) as direction, created_at, decrypt_text(attachment_filename_encrypted, $1) as attachment_filename, decrypt_text(attachment_mimetype_encrypted, $1) as attachment_mimetype, attachment_size, attachment_data, message_type - FROM messages - WHERE message_type = 'public' - ORDER BY created_at ASC`, - [encryptionKey] - ); - } - res.json(result.rows); - } catch (e) { - res.status(500).json({ error: 'DB error', details: e.message }); - } -}); - -// POST /api/messages - УСТАРЕВШИЙ эндпоинт, используйте /api/messages/send -// Оставлен для обратной совместимости, но теперь сохраняет как публичные сообщения -router.post('/', async (req, res) => { - const { user_id, sender_type, content, channel, role, direction, attachment_filename, attachment_mimetype, attachment_size, attachment_data } = req.body; - - // Получаем ключ шифрования через унифицированную утилиту - const encryptionUtils = require('../utils/encryptionUtils'); - const encryptionKey = encryptionUtils.getEncryptionKey(); - - try { - // Проверка блокировки пользователя - if (await isUserBlocked(user_id)) { - return res.status(403).json({ error: 'Пользователь заблокирован. Сообщение не принимается.' }); - } - // Проверка наличия идентификатора для выбранного канала - if (channel === 'email') { - const emailIdentity = await db.getQuery()( - 'SELECT decrypt_text(provider_id_encrypted, $3) as provider_id FROM user_identities WHERE user_id = $1 AND provider_encrypted = encrypt_text($2, $3) LIMIT 1', - [user_id, 'email', encryptionKey] - ); - if (emailIdentity.rows.length === 0) { - return res.status(400).json({ error: 'У пользователя не указан email. Сообщение не отправлено.' }); - } - } - if (channel === 'telegram') { - const tgIdentity = await db.getQuery()( - 'SELECT decrypt_text(provider_id_encrypted, $3) as provider_id FROM user_identities WHERE user_id = $1 AND provider_encrypted = encrypt_text($2, $3) LIMIT 1', - [user_id, 'telegram', encryptionKey] - ); - if (tgIdentity.rows.length === 0) { - return res.status(400).json({ error: 'У пользователя не привязан Telegram. Сообщение не отправлено.' }); - } - } - if (channel === 'wallet' || channel === 'web3' || channel === 'web') { - const walletIdentity = await db.getQuery()( - 'SELECT decrypt_text(provider_id_encrypted, $3) as provider_id FROM user_identities WHERE user_id = $1 AND provider_encrypted = encrypt_text($2, $3) LIMIT 1', - [user_id, 'wallet', encryptionKey] - ); - if (walletIdentity.rows.length === 0) { - return res.status(400).json({ error: 'У пользователя не привязан кошелёк. Сообщение не отправлено.' }); - } - } - // 1. Проверяем, есть ли беседа для user_id - 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', - [user_id, encryptionKey] - ); - let conversation; - if (conversationResult.rows.length === 0) { - // 2. Если нет — создаём новую беседу - 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] - ); - conversation = newConv.rows[0]; - } else { - conversation = conversationResult.rows[0]; - } - // 3. Сохраняем сообщение с conversation_id и типом 'public' (для обратной совместимости) - 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, attachment_filename_encrypted, attachment_mimetype_encrypted, attachment_size, attachment_data) - VALUES ($1,$2,encrypt_text($3,$13),encrypt_text($4,$13),encrypt_text($5,$13),encrypt_text($6,$13),encrypt_text($7,$13),$12,NOW(),encrypt_text($8,$13),encrypt_text($9,$13),$10,$11) RETURNING *`, - [user_id, conversation.id, sender_type, content, channel, role, direction, attachment_filename, attachment_mimetype, attachment_size, attachment_data, 'public', encryptionKey] - ); - // 4. Если это исходящее сообщение для Telegram — отправляем через бота - if (channel === 'telegram' && direction === 'out') { - try { - // console.log(`[messages.js] Попытка отправки сообщения в Telegram для user_id=${user_id}`); - // Получаем Telegram ID пользователя - const tgIdentity = await db.getQuery()( - 'SELECT decrypt_text(provider_id_encrypted, $3) as provider_id FROM user_identities WHERE user_id = $1 AND provider_encrypted = encrypt_text($2, $3) LIMIT 1', - [user_id, 'telegram', encryptionKey] - ); - // console.log(`[messages.js] Результат поиска Telegram ID:`, tgIdentity.rows); - if (tgIdentity.rows.length > 0) { - const telegramId = tgIdentity.rows[0].provider_id; - // console.log(`[messages.js] Отправка сообщения в Telegram ID: ${telegramId}, текст: ${content}`); - try { - const telegramBot = botManager.getBot('telegram'); - if (telegramBot && telegramBot.isInitialized) { - const bot = telegramBot.getBot(); - const sendResult = await bot.telegram.sendMessage(telegramId, content); - // console.log(`[messages.js] Результат отправки в Telegram:`, sendResult); - } else { - logger.warn('[messages.js] Telegram Bot не инициализирован'); - } - } catch (sendErr) { - // console.error(`[messages.js] Ошибка при отправке в Telegram:`, sendErr); - } - } else { - // console.warn(`[messages.js] Не найден Telegram ID для user_id=${user_id}`); - } - } catch (err) { - // console.error('[messages.js] Ошибка отправки сообщения в Telegram:', err); - } - } - // 5. Если это исходящее сообщение для Email — отправляем email - if (channel === 'email' && direction === 'out') { - try { - // Получаем email пользователя - const emailIdentity = await db.getQuery()( - 'SELECT decrypt_text(provider_id_encrypted, $3) as provider_id FROM user_identities WHERE user_id = $1 AND provider_encrypted = encrypt_text($2, $3) LIMIT 1', - [user_id, 'email', encryptionKey] - ); - if (emailIdentity.rows.length > 0) { - const email = emailIdentity.rows[0].provider_id; - const emailBot = botManager.getBot('email'); - if (emailBot && emailBot.isInitialized) { - await emailBot.sendEmail(email, 'Новое сообщение', content); - } else { - logger.warn('[messages.js] Email Bot не инициализирован для отправки'); - } - } - } catch (err) { - // console.error('[messages.js] Ошибка отправки email:', err); - } - } - broadcastMessagesUpdate(); - res.json({ success: true, message: result.rows[0] }); - } catch (e) { - res.status(500).json({ error: 'DB error', details: e.message }); - } -}); +// УДАЛЕНО: GET /api/messages - УСТАРЕВШИЙ эндпоинт (используйте /api/messages/public или /api/messages/private) +// УДАЛЕНО: POST /api/messages - УСТАРЕВШИЙ эндпоинт (используйте /api/messages/send или /api/chat/message) // POST /api/messages/mark-read router.post('/mark-read', async (req, res) => { @@ -707,6 +468,305 @@ router.post('/send', requireAuth, async (req, res) => { } }); +// POST /api/messages/private/send - отправка приватного сообщения +router.post('/private/send', requireAuth, async (req, res) => { + const { recipientId, content } = req.body; + const senderId = req.user.id; + + if (!recipientId || !content) { + return res.status(400).json({ error: 'recipientId и content обязательны' }); + } + + try { + // Получаем информацию об отправителе и получателе + const senderResult = await db.getQuery()( + 'SELECT id, role FROM users WHERE id = $1', + [senderId] + ); + + 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]; + + // Проверяем права: только к админам-редакторам + if (recipient.role !== 'editor') { + return res.status(403).json({ + error: 'Приватные сообщения можно отправлять только админам-редакторам' + }); + } + + // Получаем ключ шифрования + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + + // Находим или создаем приватную беседу + 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; + } + + // Сохраняем приватное сообщение + 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 + ] + ); + + // Обновляем время последнего обновления беседы + await db.getQuery()( + 'UPDATE conversations SET updated_at = NOW() WHERE id = $1', + [conversationId] + ); + + // Отправляем обновление через WebSocket + const { broadcastMessagesUpdate } = require('../wsHub'); + broadcastMessagesUpdate(); + + res.json({ + success: true, + messageId: result.rows[0].id, + conversationId: conversationId + }); + + } catch (error) { + console.error('[ERROR] /messages/private/send:', error); + res.status(500).json({ error: 'DB error', details: error.message }); + } +}); + +// GET /api/messages/private/conversations - получить приватные чаты пользователя +router.get('/private/conversations', requireAuth, async (req, res) => { + const currentUserId = req.user.id; + console.log('[DEBUG] /messages/private/conversations currentUserId:', currentUserId); + + try { + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + + // Получаем приватные чаты где пользователь является участником + const result = await db.getQuery()( + `SELECT DISTINCT + c.id as conversation_id, + c.user_id, + decrypt_text(c.title_encrypted, $2) as 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' + WHERE cp.user_id = $1 AND c.conversation_type = 'private' + GROUP BY c.id, c.user_id, c.title_encrypted, c.updated_at + ORDER BY c.updated_at DESC`, + [currentUserId, encryptionKey] + ); + + console.log('[DEBUG] /messages/private/conversations result:', result.rows); + + res.json({ + success: true, + conversations: result.rows + }); + + } catch (error) { + console.error('[ERROR] /messages/private/conversations:', error); + res.status(500).json({ error: 'DB error', details: error.message }); + } +}); + +// 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; + + try { + // Подсчитываем непрочитанные приватные сообщения для текущего пользователя + const result = await db.getQuery()( + `SELECT COUNT(*) as unread_count + FROM messages m + INNER JOIN conversations c ON m.conversation_id = c.id + 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.user_id = $1 -- сообщения адресованные текущему пользователю + AND m.sender_id != $1 -- исключаем собственные сообщения + AND NOT EXISTS ( + SELECT 1 FROM admin_read_messages arm + WHERE arm.admin_id = $1 + AND arm.user_id = $1 + AND arm.last_read_at >= m.created_at + )`, + [currentUserId] + ); + + const unreadCount = parseInt(result.rows[0].unread_count) || 0; + + res.json({ + success: true, + unreadCount: unreadCount + }); + + } catch (error) { + console.error('[ERROR] /messages/private/unread-count:', error); + res.status(500).json({ error: 'DB error', details: error.message }); + } +}); + +// POST /api/messages/private/mark-read - отметить приватные сообщения как прочитанные +router.post('/private/mark-read', requireAuth, async (req, res) => { + const { conversationId } = req.body; + const currentUserId = req.user.id; + + if (!conversationId) { + return res.status(400).json({ error: 'conversationId обязателен' }); + } + + 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: 'Доступ запрещен' }); + } + + // Отмечаем сообщения как прочитанные + await db.getQuery()( + `INSERT INTO admin_read_messages (admin_id, user_id, last_read_at) + VALUES ($1, $1, NOW()) + ON CONFLICT (admin_id, user_id) + DO UPDATE SET last_read_at = NOW()`, + [currentUserId] + ); + + // Отправляем обновление через WebSocket + broadcastMessagesUpdate(); + + res.json({ success: true }); + + } catch (error) { + console.error('[ERROR] /messages/private/mark-read:', 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; diff --git a/backend/scripts/contracts-data/modules-deploy-summary.json b/backend/scripts/contracts-data/modules-deploy-summary.json deleted file mode 100644 index 7ceefeb..0000000 --- a/backend/scripts/contracts-data/modules-deploy-summary.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "deploymentId": "modules-deploy-1759239712369", - "dleAddress": "0x7DB7E55eA9050105cDeeC892A1B8D4Ea335b0BFD", - "dleName": "DLE-8", - "dleSymbol": "TOKEN", - "dleLocation": "101000, Москва, Москва, тверская, 1, 1", - "dleJurisdiction": 643, - "dleCoordinates": "55.7715511,37.5929598", - "dleOktmo": "45000000", - "dleOkvedCodes": [ - "63.11", - "62.01" - ], - "dleKpp": "773009001", - "dleLogoURI": "/uploads/logos/default-token.svg", - "dleSupportedChainIds": [ - 11155111, - 84532, - 17000, - 421614 - ], - "totalNetworks": 4, - "successfulNetworks": 4, - "modulesDeployed": [ - "hierarchicalVoting" - ], - "networks": [ - { - "chainId": 11155111, - "rpcUrl": "https://1rpc.io/sepolia", - "modules": [ - { - "type": "hierarchicalVoting", - "address": "0x47793D53daaf0907b819aE31E2478ef5680FC895", - "success": true, - "verification": "verified" - } - ] - }, - { - "chainId": 84532, - "rpcUrl": "https://sepolia.base.org", - "modules": [ - { - "type": "hierarchicalVoting", - "address": "0x47793D53daaf0907b819aE31E2478ef5680FC895", - "success": true, - "verification": "verified" - } - ] - }, - { - "chainId": 17000, - "rpcUrl": "https://ethereum-holesky.publicnode.com", - "modules": [ - { - "type": "hierarchicalVoting", - "address": "0x47793D53daaf0907b819aE31E2478ef5680FC895", - "success": true, - "verification": "verified" - } - ] - }, - { - "chainId": 421614, - "rpcUrl": "https://sepolia-rollup.arbitrum.io/rpc", - "modules": [ - { - "type": "hierarchicalVoting", - "address": "0x47793D53daaf0907b819aE31E2478ef5680FC895", - "success": true, - "verification": "verified" - } - ] - } - ], - "timestamp": "2025-09-30T13:41:52.370Z" -} \ No newline at end of file diff --git a/backend/services/UniversalGuestService.js b/backend/services/UniversalGuestService.js index dbcc0c2..eb70289 100644 --- a/backend/services/UniversalGuestService.js +++ b/backend/services/UniversalGuestService.js @@ -460,17 +460,18 @@ class UniversalGuestService { attachment_mimetype_encrypted, attachment_size, attachment_data, + message_type, 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), - $10, $11, $12 + 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 )`, [ userId, @@ -484,6 +485,7 @@ class UniversalGuestService { msg.attachment_mimetype, msg.attachment_size, msg.attachment_data, + 'public', // message_type для мигрированных сообщений msg.created_at, encryptionKey ] diff --git a/backend/services/adminLogicService.js b/backend/services/adminLogicService.js index f06a73b..13f8791 100644 --- a/backend/services/adminLogicService.js +++ b/backend/services/adminLogicService.js @@ -29,18 +29,13 @@ const logger = require('../utils/logger'); function shouldGenerateAiReply(params) { const { senderType, userId, recipientId } = params; - // Обычные пользователи всегда получают AI ответ + // Обычные пользователи (USER, READONLY) всегда получают AI ответ if (senderType !== 'editor') { return true; } - // Админ, пишущий себе, получает AI ответ - if (userId === recipientId) { - return true; - } - - // Админ, пишущий другому пользователю, не получает AI ответ - // (это личное сообщение от админа) + // Админы-редакторы (EDITOR) НЕ получают AI ответы + // ни себе, ни другим админам (по спецификации) return false; } diff --git a/backend/services/session-service.js b/backend/services/session-service.js index c56f442..9be8ea3 100644 --- a/backend/services/session-service.js +++ b/backend/services/session-service.js @@ -130,9 +130,16 @@ class SessionService { */ async isGuestIdProcessed(guestId) { try { - const result = await encryptedDb.getData('unified_guest_mapping', { identifier_encrypted: `web:${guestId}` }); + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); - return result.length > 0 && result[0].processed === true; + const result = await db.getQuery()( + `SELECT * FROM unified_guest_mapping + WHERE decrypt_text(identifier_encrypted, $2) = $1 AND processed = true`, + [`web:${guestId}`, encryptionKey] + ); + + return result.rows.length > 0; } catch (error) { logger.error(`[isGuestIdProcessed] Error checking guest ID ${guestId}:`, error); return false; diff --git a/frontend/src/components/ChatInterface.vue b/frontend/src/components/ChatInterface.vue index 1fb5d7a..00bb350 100644 --- a/frontend/src/components/ChatInterface.vue +++ b/frontend/src/components/ChatInterface.vue @@ -17,7 +17,11 @@ - + @@ -131,7 +135,11 @@ const props = defineProps({ // Новые props для точного контроля прав canSend: { type: Boolean, default: true }, // Может отправлять сообщения canGenerateAI: { type: Boolean, default: false }, // Может генерировать AI-ответы - canSelectMessages: { type: Boolean, default: false } // Может выбирать сообщения + canSelectMessages: { type: Boolean, default: false }, // Может выбирать сообщения + + // Props для приватного чата + isPrivateChat: { type: Boolean, default: false }, // Это приватный чат + currentUserId: { type: [String, Number], default: null } // ID текущего пользователя }); const emit = defineEmits([ @@ -833,4 +841,20 @@ async function handleAiReply() { .admin-select-checkbox { margin-right: 8px; } + +/* Стили для приватного чата */ +.message-wrapper { + display: flex; + align-items: flex-start; + margin-bottom: 12px; +} + +/* Для приватного чата выравниваем сообщения по сторонам */ +.chat-messages:has(.private-current-user) .message-wrapper { + justify-content: flex-end; +} + +.chat-messages:has(.private-other-user) .message-wrapper { + justify-content: flex-start; +} \ No newline at end of file diff --git a/frontend/src/components/ContactTable.vue b/frontend/src/components/ContactTable.vue index 7d91e8e..d11a178 100644 --- a/frontend/src/components/ContactTable.vue +++ b/frontend/src/components/ContactTable.vue @@ -14,7 +14,10 @@
- Личные сообщения + + Личные сообщения + + Публичное сообщение Приватное сообщение Рассылка @@ -130,7 +133,7 @@ import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSo import { usePermissions } from '@/composables/usePermissions'; import { useAuthContext } from '@/composables/useAuth'; import api from '../api/axios'; -import { sendMessage } from '../services/messagesService'; +import { sendMessage, getPrivateUnreadCount } from '../services/messagesService'; import { useRoles } from '@/composables/useRoles'; const props = defineProps({ contacts: { type: Array, default: () => [] }, @@ -156,6 +159,22 @@ const filterDateTo = ref(''); const filterNewMessages = ref(''); const filterBlocked = ref('all'); +// Уведомления для приватных сообщений +const privateUnreadCount = ref(0); + +// Функция для загрузки количества непрочитанных приватных сообщений +async function loadPrivateUnreadCount() { + try { + const response = await getPrivateUnreadCount(); + if (response.success) { + privateUnreadCount.value = response.unreadCount || 0; + } + } catch (error) { + console.error('[ContactTable] Ошибка загрузки непрочитанных сообщений:', error); + privateUnreadCount.value = 0; + } +} + // Новый фильтр тегов через мультисвязи const availableTags = ref([]); const selectedTagIds = ref([]); @@ -255,6 +274,7 @@ onMounted(async () => { if (isAuthenticated.value) { try { await fetchRoles(); + await loadPrivateUnreadCount(); } catch (error) { console.log('[ContactTable] Ошибка загрузки ролей в onMounted:', error.message); } @@ -437,28 +457,15 @@ async function sendPublicMessage() { } } -// Новая функция для отправки приватного сообщения -async function sendPrivateMessage() { - if (selectedIds.value.length === 0) return; - - const contactId = selectedIds.value[0]; - const contact = filteredContacts.value.find(c => c.id === contactId); - if (!contact) return; - - try { - const content = prompt('Введите текст приватного сообщения:'); - if (!content) return; - - await sendMessage({ - recipientId: contactId, - content, - messageType: 'private' - }); - - ElMessage.success('Приватное сообщение отправлено'); - } catch (error) { - ElMessage.error('Ошибка отправки сообщения: ' + (error.message || error)); +// Функция для открытия приватного чата +function sendPrivateMessage() { + if (selectedIds.value.length === 0) { + ElMessage.warning('Выберите контакт для отправки приватного сообщения'); + return; } + + // Открываем приватный чат вместо отправки через prompt + openPrivateChatForSelected(); } async function openPrivateChatForSelected(contact = null) { @@ -746,4 +753,8 @@ async function deleteMessagesSelected() { font-size: 0.85em; font-weight: 500; } + +.notification-badge { + margin-left: 8px; +} \ No newline at end of file diff --git a/frontend/src/components/Message.vue b/frontend/src/components/Message.vue index ed12949..4b1a933 100644 --- a/frontend/src/components/Message.vue +++ b/frontend/src/components/Message.vue @@ -14,11 +14,13 @@
{ + // Для приватного чата используем sender_id и currentUserId + if (props.isPrivateChat && props.currentUserId) { + return props.message.sender_id == props.currentUserId; + } + // Если это admin_chat, используем sender_id для определения if (props.message.message_type === 'admin_chat') { // Для простоты, считаем что если sender_id равен user_id, то это ответное сообщение @@ -500,4 +515,50 @@ function copyEmail(email) { .read-status:contains('✓') { color: var(--color-success, #10b981); } + +/* Стили для приватного чата */ +.private-current-user { + background: linear-gradient(135deg, #3b82f6, #1d4ed8); /* Синий градиент */ + color: white; + margin-left: auto; + margin-right: 0; + max-width: 70%; + border-radius: 18px 18px 4px 18px; + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); +} + +.private-other-user { + background: linear-gradient(135deg, #10b981, #059669); /* Зеленый градиент */ + color: white; + margin-left: 0; + margin-right: auto; + max-width: 70%; + border-radius: 18px 18px 18px 4px; + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); +} + +/* Анимация появления сообщений */ +.private-current-user, +.private-other-user { + animation: slideInMessage 0.3s ease-out; +} + +@keyframes slideInMessage { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Адаптивность для мобильных устройств */ +@media (max-width: 768px) { + .private-current-user, + .private-other-user { + max-width: 85%; + } +} \ No newline at end of file diff --git a/frontend/src/services/messagesService.js b/frontend/src/services/messagesService.js index a022506..c31aa34 100644 --- a/frontend/src/services/messagesService.js +++ b/frontend/src/services/messagesService.js @@ -12,6 +12,13 @@ import api from '@/api/axios'; +// Вспомогательные функции для экспорта +async function getConversationByUserId(userId) { + if (!userId) return null; + const { data } = await api.get(`/messages/conversations?userId=${userId}`); + return data; +} + export default { async getMessagesByUserId(userId) { if (!userId) return []; @@ -40,9 +47,7 @@ export default { return data; }, async getConversationByUserId(userId) { - if (!userId) return null; - const { data } = await api.get(`/messages/conversations?userId=${userId}`); - return data; + return getConversationByUserId(userId); }, async generateAiDraft(conversationId, messages, language = 'auto') { const { data } = await api.post('/chat/ai-draft', { conversationId, messages, language }); @@ -65,6 +70,9 @@ export default { } }; +// Экспортируем функцию для использования в других компонентах +export { getConversationByUserId }; + export async function getAllMessages() { // Используем новый API для публичных сообщений const { data } = await api.get('/messages/public'); @@ -73,12 +81,22 @@ export async function getAllMessages() { // Новые методы для работы с типами сообщений export async function sendMessage({ recipientId, content, messageType = 'public' }) { - const { data } = await api.post('/messages/send', { - recipientId, - content, - messageType - }); - return data; + if (messageType === 'private') { + // Используем новый API для приватных сообщений + const { data } = await api.post('/messages/private/send', { + recipientId, + content + }); + return data; + } else { + // Используем старый API для публичных сообщений + const { data } = await api.post('/messages/send', { + recipientId, + content, + messageType + }); + return data; + } } export async function getPublicMessages(userId = null, options = {}) { @@ -88,10 +106,6 @@ export async function getPublicMessages(userId = null, options = {}) { return data; } -export async function getPrivateMessages(options = {}) { - const { data } = await api.get('/messages/private', { params: options }); - return data; -} // Новые функции для работы с диалогами export async function getConversations(userId) { @@ -120,4 +134,36 @@ export async function getReadStatus() { export async function deleteMessageHistory(userId) { const { data } = await api.delete(`/messages/delete-history/${userId}`); return data; +} + +// Новые функции для приватных сообщений +export async function getPrivateConversations() { + const { data } = await api.get('/messages/private/conversations'); + return data; +} + +export async function getPrivateMessages(conversationId) { + const { data } = await api.get(`/messages/private/${conversationId}`); + return data; +} + +export async function sendPrivateMessage({ recipientId, content }) { + const { data } = await api.post('/messages/private/send', { + recipientId, + content + }); + return data; +} + +// Функции для работы с уведомлениями +export async function getPrivateUnreadCount() { + const { data } = await api.get('/messages/private/unread-count'); + return data; +} + +export async function markPrivateMessagesAsRead(conversationId) { + const { data } = await api.post('/messages/private/mark-read', { + conversationId + }); + return data; } \ No newline at end of file diff --git a/frontend/src/views/AdminChatView.vue b/frontend/src/views/AdminChatView.vue index c11aa0c..ee44cb3 100644 --- a/frontend/src/views/AdminChatView.vue +++ b/frontend/src/views/AdminChatView.vue @@ -30,6 +30,8 @@ :canSend="true" :canGenerateAI="false" :canSelectMessages="false" + :isPrivateChat="true" + :currentUserId="currentUserId" @send-message="handleSendMessage" @update:newMessage="val => chatNewMessage = val" @update:attachments="val => chatAttachments = val" @@ -44,12 +46,15 @@ import { ref, onMounted, computed } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import BaseLayout from '../components/BaseLayout.vue'; import ChatInterface from '../components/ChatInterface.vue'; -import adminChatService from '../services/adminChatService.js'; +import { getPrivateMessages, sendPrivateMessage, getPrivateConversations, markPrivateMessagesAsRead } from '../services/messagesService.js'; +import { useAuthContext } from '@/composables/useAuth'; const route = useRoute(); const router = useRouter(); +const { userId } = useAuthContext(); const adminId = computed(() => route.params.adminId); +const currentUserId = computed(() => userId.value); const messages = ref([]); const chatAttachments = ref([]); const chatNewMessage = ref(''); @@ -60,12 +65,35 @@ async function loadMessages() { try { isLoadingMessages.value = true; - console.log('[AdminChatView] Загружаем сообщения для админа:', adminId.value); + console.log('[AdminChatView] Загружаем приватные сообщения для админа:', adminId.value); - const response = await adminChatService.getMessages(adminId.value); - console.log('[AdminChatView] Получен ответ:', response); + // Получаем приватные чаты пользователя + const conversationsResponse = await getPrivateConversations(); + console.log('[AdminChatView] Приватные чаты:', conversationsResponse); + + // Находим чат с нужным админом + const conversation = conversationsResponse.conversations?.find(conv => + conv.user_id == adminId.value + ); + + if (conversation) { + // Загружаем историю чата + const messagesResponse = await getPrivateMessages(conversation.conversation_id); + console.log('[AdminChatView] История чата:', messagesResponse); + + messages.value = messagesResponse?.messages || []; + + // Отмечаем сообщения как прочитанные + try { + await markPrivateMessagesAsRead(conversation.conversation_id); + console.log('[AdminChatView] Сообщения отмечены как прочитанные'); + } catch (error) { + console.error('[AdminChatView] Ошибка отметки сообщений как прочитанных:', error); + } + } else { + messages.value = []; + } - messages.value = response?.messages || []; console.log('[AdminChatView] Загружено сообщений:', messages.value.length); } catch (error) { console.error('[AdminChatView] Ошибка загрузки сообщений:', error); @@ -79,9 +107,12 @@ async function handleSendMessage({ message, attachments }) { if (!message.trim() || !adminId.value) return; try { - console.log('[AdminChatView] Отправляем сообщение:', message, 'админу:', adminId.value); + console.log('[AdminChatView] Отправляем приватное сообщение:', message, 'админу:', adminId.value); - await adminChatService.sendMessage(adminId.value, message, attachments); + await sendPrivateMessage({ + recipientId: parseInt(adminId.value), + content: message + }); // Очищаем поле ввода chatNewMessage.value = ''; diff --git a/frontend/src/views/PersonalMessagesView.vue b/frontend/src/views/PersonalMessagesView.vue index ade6368..58a7880 100644 --- a/frontend/src/views/PersonalMessagesView.vue +++ b/frontend/src/views/PersonalMessagesView.vue @@ -37,7 +37,7 @@
{{ message.last_message || 'Нет сообщений' }}
{{ formatDate(message.last_message_at) }}
- + Открыть
@@ -51,7 +51,7 @@ import { useRouter, useRoute } from 'vue-router'; import BaseLayout from '../components/BaseLayout.vue'; import adminChatService from '../services/adminChatService.js'; import { usePermissions } from '@/composables/usePermissions'; -import { getPrivateMessages } from '../services/messagesService'; +import { getPrivateConversations } from '../services/messagesService'; const router = useRouter(); const route = useRoute(); @@ -66,41 +66,43 @@ let ws = null; async function fetchPersonalMessages() { try { isLoading.value = true; - console.log('[PersonalMessagesView] Загружаем приватные сообщения...'); + console.log('[PersonalMessagesView] Загружаем приватные чаты...'); - // Загружаем приватные сообщения через новый API с пагинацией - const response = await getPrivateMessages({ limit: 100, offset: 0 }); - console.log('[PersonalMessagesView] Загружено приватных сообщений:', response.messages?.length || 0); + // Загружаем приватные чаты через новый API + const response = await getPrivateConversations(); + console.log('[PersonalMessagesView] Загружено приватных чатов:', response.conversations?.length || 0); - const privateMessages = response.success && response.messages ? response.messages : []; + const conversations = response.success && response.conversations ? response.conversations : []; - // Группируем сообщения по отправителям для отображения списка бесед - const messageGroups = {}; - privateMessages.forEach(msg => { - const senderId = msg.sender_id || 'unknown'; - if (!messageGroups[senderId]) { - messageGroups[senderId] = { - id: senderId, - name: `Админ ${senderId}`, - last_message: msg.content, - last_message_at: msg.created_at, - messages: [] - }; - } - messageGroups[senderId].messages.push(msg); - // Обновляем последнее сообщение - if (new Date(msg.created_at) > new Date(messageGroups[senderId].last_message_at)) { - messageGroups[senderId].last_message = msg.content; - messageGroups[senderId].last_message_at = msg.created_at; - } + console.log('[PersonalMessagesView] Полученные conversations:', conversations); + + // Проверяем, что у нас есть данные + if (!conversations || conversations.length === 0) { + console.log('[PersonalMessagesView] Нет приватных чатов'); + personalMessages.value = []; + newMessagesCount.value = 0; + return; + } + + // Формируем список бесед + personalMessages.value = conversations.map(conv => { + console.log('[PersonalMessagesView] Обрабатываем conversation:', conv); + return { + id: conv.conversation_id, + conversation_id: conv.conversation_id, + user_id: conv.user_id, + name: conv.title || `Чат с пользователем ${conv.user_id}`, + last_message: 'Приватный чат', + last_message_at: conv.updated_at, + message_count: conv.message_count || 0 + }; }); - personalMessages.value = Object.values(messageGroups); newMessagesCount.value = personalMessages.value.length; console.log('[PersonalMessagesView] Сформировано бесед:', personalMessages.value.length); } catch (error) { - console.error('[PersonalMessagesView] Ошибка загрузки приватных сообщений:', error); + console.error('[PersonalMessagesView] Ошибка загрузки приватных чатов:', error); personalMessages.value = []; } finally { isLoading.value = false; @@ -156,9 +158,19 @@ function disconnectWebSocket() { } } -function openPersonalChat(adminId) { - console.log('[PersonalMessagesView] Открываем приватный чат с админом:', adminId); - router.push({ name: 'admin-chat', params: { adminId } }); +function openPersonalChat(conversation) { + console.log('[PersonalMessagesView] Открываем приватный чат:', conversation); + + // Проверяем, что у нас есть user_id + if (!conversation.user_id) { + console.error('[PersonalMessagesView] Ошибка: user_id не найден в conversation:', conversation); + return; + } + + // Переходим к чату с ID админа (user_id в conversation) + const adminId = parseInt(conversation.user_id); + console.log('[PersonalMessagesView] Переходим к чату с adminId:', adminId); + router.push({ name: 'admin-chat', params: { adminId: adminId } }); } function goBack() {