feat: новая функция

This commit is contained in:
2025-10-17 16:38:54 +03:00
parent 927d174f66
commit e2471e127d
12 changed files with 593 additions and 420 deletions

View File

@@ -35,7 +35,7 @@ const upload = multer({ storage: storage });
router.post('/guest-message', upload.array('attachments'), async (req, res) => { router.post('/guest-message', upload.array('attachments'), async (req, res) => {
try { try {
// Frontend отправляет FormData, поэтому читаем из req.body // Frontend отправляет FormData, поэтому читаем из req.body
const content = req.body.content || req.body.message; const content = req.body.message;
const guestId = req.body.guestId; const guestId = req.body.guestId;
const files = req.files || []; 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) => { router.post('/message', requireAuth, upload.array('attachments'), async (req, res) => {
try { 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 userId = req.session.userId;
const files = req.files || []; const files = req.files || [];

View File

@@ -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 { // УДАЛЕНО: GET /api/messages - УСТАРЕВШИЙ эндпоинт (используйте /api/messages/public или /api/messages/private)
// Проверяем, это гостевой идентификатор (формат: guest_123) // УДАЛЕНО: POST /api/messages - УСТАРЕВШИЙ эндпоинт (используйте /api/messages/send или /api/chat/message)
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 });
}
});
// POST /api/messages/mark-read // POST /api/messages/mark-read
router.post('/mark-read', async (req, res) => { 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 - получить диалоги пользователя // GET /api/messages/conversations?userId=123 - получить диалоги пользователя
router.get('/conversations', requireAuth, async (req, res) => { router.get('/conversations', requireAuth, async (req, res) => {
const userId = req.query.userId; const userId = req.query.userId;

View File

@@ -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"
}

View File

@@ -460,17 +460,18 @@ class UniversalGuestService {
attachment_mimetype_encrypted, attachment_mimetype_encrypted,
attachment_size, attachment_size,
attachment_data, attachment_data,
message_type,
created_at created_at
) VALUES ( ) VALUES (
$1, $2, $1, $2,
encrypt_text($3, $13), encrypt_text($3, $14),
encrypt_text($4, $13), encrypt_text($4, $14),
encrypt_text($5, $13), encrypt_text($5, $14),
encrypt_text($6, $13), encrypt_text($6, $14),
encrypt_text($7, $13), encrypt_text($7, $14),
encrypt_text($8, $13), encrypt_text($8, $14),
encrypt_text($9, $13), encrypt_text($9, $14),
$10, $11, $12 $10, $11, $12, $13
)`, )`,
[ [
userId, userId,
@@ -484,6 +485,7 @@ class UniversalGuestService {
msg.attachment_mimetype, msg.attachment_mimetype,
msg.attachment_size, msg.attachment_size,
msg.attachment_data, msg.attachment_data,
'public', // message_type для мигрированных сообщений
msg.created_at, msg.created_at,
encryptionKey encryptionKey
] ]

View File

@@ -29,18 +29,13 @@ const logger = require('../utils/logger');
function shouldGenerateAiReply(params) { function shouldGenerateAiReply(params) {
const { senderType, userId, recipientId } = params; const { senderType, userId, recipientId } = params;
// Обычные пользователи всегда получают AI ответ // Обычные пользователи (USER, READONLY) всегда получают AI ответ
if (senderType !== 'editor') { if (senderType !== 'editor') {
return true; return true;
} }
// Админ, пишущий себе, получает AI ответ // Админы-редакторы (EDITOR) НЕ получают AI ответы
if (userId === recipientId) { // ни себе, ни другим админам (по спецификации)
return true;
}
// Админ, пишущий другому пользователю, не получает AI ответ
// (это личное сообщение от админа)
return false; return false;
} }

View File

@@ -130,9 +130,16 @@ class SessionService {
*/ */
async isGuestIdProcessed(guestId) { async isGuestIdProcessed(guestId) {
try { 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) { } catch (error) {
logger.error(`[isGuestIdProcessed] Error checking guest ID ${guestId}:`, error); logger.error(`[isGuestIdProcessed] Error checking guest ID ${guestId}:`, error);
return false; return false;

View File

@@ -17,7 +17,11 @@
<template v-if="props.canSelectMessages"> <template v-if="props.canSelectMessages">
<input type="checkbox" class="admin-select-checkbox" :checked="selectedMessageIds.includes(message.id)" @change="() => toggleSelectMessage(message.id)" /> <input type="checkbox" class="admin-select-checkbox" :checked="selectedMessageIds.includes(message.id)" @change="() => toggleSelectMessage(message.id)" />
</template> </template>
<Message :message="message" /> <Message
:message="message"
:isPrivateChat="isPrivateChat"
:currentUserId="currentUserId"
/>
</div> </div>
</div> </div>
@@ -131,7 +135,11 @@ const props = defineProps({
// Новые props для точного контроля прав // Новые props для точного контроля прав
canSend: { type: Boolean, default: true }, // Может отправлять сообщения canSend: { type: Boolean, default: true }, // Может отправлять сообщения
canGenerateAI: { type: Boolean, default: false }, // Может генерировать AI-ответы 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([ const emit = defineEmits([
@@ -833,4 +841,20 @@ async function handleAiReply() {
.admin-select-checkbox { .admin-select-checkbox {
margin-right: 8px; 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;
}
</style> </style>

View File

@@ -14,7 +14,10 @@
<div class="contact-table-modal"> <div class="contact-table-modal">
<div class="contact-table-header"> <div class="contact-table-header">
<!-- Кнопка "Личные сообщения" для всех пользователей --> <!-- Кнопка "Личные сообщения" для всех пользователей -->
<el-button v-if="canChatWithAdmins" type="info" @click="goToPersonalMessages" style="margin-right: 1em;">Личные сообщения</el-button> <el-button v-if="canChatWithAdmins" type="info" @click="goToPersonalMessages" style="margin-right: 1em;">
Личные сообщения
<el-badge v-if="privateUnreadCount > 0" :value="privateUnreadCount" class="notification-badge" />
</el-button>
<el-button v-if="canSendToUsers" type="success" :disabled="!hasSelectedEditor" @click="sendPublicMessage" style="margin-right: 1em;">Публичное сообщение</el-button> <el-button v-if="canSendToUsers" type="success" :disabled="!hasSelectedEditor" @click="sendPublicMessage" style="margin-right: 1em;">Публичное сообщение</el-button>
<el-button v-if="canViewContacts" type="warning" :disabled="!hasSelectedEditor" @click="sendPrivateMessage" style="margin-right: 1em;">Приватное сообщение</el-button> <el-button v-if="canViewContacts" type="warning" :disabled="!hasSelectedEditor" @click="sendPrivateMessage" style="margin-right: 1em;">Приватное сообщение</el-button>
<el-button v-if="canManageSettings" type="info" :disabled="!selectedIds.length" @click="showBroadcastModal = true" style="margin-right: 1em;">Рассылка</el-button> <el-button v-if="canManageSettings" type="info" :disabled="!selectedIds.length" @click="showBroadcastModal = true" style="margin-right: 1em;">Рассылка</el-button>
@@ -130,7 +133,7 @@ import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSo
import { usePermissions } from '@/composables/usePermissions'; import { usePermissions } from '@/composables/usePermissions';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import api from '../api/axios'; import api from '../api/axios';
import { sendMessage } from '../services/messagesService'; import { sendMessage, getPrivateUnreadCount } from '../services/messagesService';
import { useRoles } from '@/composables/useRoles'; import { useRoles } from '@/composables/useRoles';
const props = defineProps({ const props = defineProps({
contacts: { type: Array, default: () => [] }, contacts: { type: Array, default: () => [] },
@@ -156,6 +159,22 @@ const filterDateTo = ref('');
const filterNewMessages = ref(''); const filterNewMessages = ref('');
const filterBlocked = ref('all'); 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 availableTags = ref([]);
const selectedTagIds = ref([]); const selectedTagIds = ref([]);
@@ -255,6 +274,7 @@ onMounted(async () => {
if (isAuthenticated.value) { if (isAuthenticated.value) {
try { try {
await fetchRoles(); await fetchRoles();
await loadPrivateUnreadCount();
} catch (error) { } catch (error) {
console.log('[ContactTable] Ошибка загрузки ролей в onMounted:', error.message); console.log('[ContactTable] Ошибка загрузки ролей в onMounted:', error.message);
} }
@@ -437,28 +457,15 @@ async function sendPublicMessage() {
} }
} }
// Новая функция для отправки приватного сообщения // Функция для открытия приватного чата
async function sendPrivateMessage() { function sendPrivateMessage() {
if (selectedIds.value.length === 0) return; if (selectedIds.value.length === 0) {
ElMessage.warning('Выберите контакт для отправки приватного сообщения');
const contactId = selectedIds.value[0]; return;
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));
} }
// Открываем приватный чат вместо отправки через prompt
openPrivateChatForSelected();
} }
async function openPrivateChatForSelected(contact = null) { async function openPrivateChatForSelected(contact = null) {
@@ -746,4 +753,8 @@ async function deleteMessagesSelected() {
font-size: 0.85em; font-size: 0.85em;
font-weight: 500; font-weight: 500;
} }
.notification-badge {
margin-left: 8px;
}
</style> </style>

View File

@@ -14,11 +14,13 @@
<div <div
:class="[ :class="[
'message', 'message',
message.sender_type === 'assistant' || message.role === 'assistant' isPrivateChat
? 'ai-message' ? (isCurrentUserMessage ? 'private-current-user' : 'private-other-user')
: message.sender_type === 'system' || message.role === 'system' : message.sender_type === 'assistant' || message.role === 'assistant'
? 'system-message' ? 'ai-message'
: 'user-message', : message.sender_type === 'system' || message.role === 'system'
? 'system-message'
: 'user-message',
message.isLocal ? 'is-local' : '', message.isLocal ? 'is-local' : '',
message.hasError ? 'has-error' : '', message.hasError ? 'has-error' : '',
]" ]"
@@ -100,11 +102,24 @@ const props = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
isPrivateChat: {
type: Boolean,
default: false,
},
currentUserId: {
type: [String, Number],
default: null,
},
}); });
// Простая функция для определения, является ли сообщение отправленным текущим пользователем // Простая функция для определения, является ли сообщение отправленным текущим пользователем
// Используем данные из самого сообщения для определения направления // Используем данные из самого сообщения для определения направления
const isCurrentUserMessage = computed(() => { const isCurrentUserMessage = computed(() => {
// Для приватного чата используем sender_id и currentUserId
if (props.isPrivateChat && props.currentUserId) {
return props.message.sender_id == props.currentUserId;
}
// Если это admin_chat, используем sender_id для определения // Если это admin_chat, используем sender_id для определения
if (props.message.message_type === 'admin_chat') { if (props.message.message_type === 'admin_chat') {
// Для простоты, считаем что если sender_id равен user_id, то это ответное сообщение // Для простоты, считаем что если sender_id равен user_id, то это ответное сообщение
@@ -500,4 +515,50 @@ function copyEmail(email) {
.read-status:contains('✓') { .read-status:contains('✓') {
color: var(--color-success, #10b981); 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%;
}
}
</style> </style>

View File

@@ -12,6 +12,13 @@
import api from '@/api/axios'; 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 { export default {
async getMessagesByUserId(userId) { async getMessagesByUserId(userId) {
if (!userId) return []; if (!userId) return [];
@@ -40,9 +47,7 @@ export default {
return data; return data;
}, },
async getConversationByUserId(userId) { async getConversationByUserId(userId) {
if (!userId) return null; return getConversationByUserId(userId);
const { data } = await api.get(`/messages/conversations?userId=${userId}`);
return data;
}, },
async generateAiDraft(conversationId, messages, language = 'auto') { async generateAiDraft(conversationId, messages, language = 'auto') {
const { data } = await api.post('/chat/ai-draft', { conversationId, messages, language }); const { data } = await api.post('/chat/ai-draft', { conversationId, messages, language });
@@ -65,6 +70,9 @@ export default {
} }
}; };
// Экспортируем функцию для использования в других компонентах
export { getConversationByUserId };
export async function getAllMessages() { export async function getAllMessages() {
// Используем новый API для публичных сообщений // Используем новый API для публичных сообщений
const { data } = await api.get('/messages/public'); const { data } = await api.get('/messages/public');
@@ -73,12 +81,22 @@ export async function getAllMessages() {
// Новые методы для работы с типами сообщений // Новые методы для работы с типами сообщений
export async function sendMessage({ recipientId, content, messageType = 'public' }) { export async function sendMessage({ recipientId, content, messageType = 'public' }) {
const { data } = await api.post('/messages/send', { if (messageType === 'private') {
recipientId, // Используем новый API для приватных сообщений
content, const { data } = await api.post('/messages/private/send', {
messageType recipientId,
}); content
return data; });
return data;
} else {
// Используем старый API для публичных сообщений
const { data } = await api.post('/messages/send', {
recipientId,
content,
messageType
});
return data;
}
} }
export async function getPublicMessages(userId = null, options = {}) { export async function getPublicMessages(userId = null, options = {}) {
@@ -88,10 +106,6 @@ export async function getPublicMessages(userId = null, options = {}) {
return data; return data;
} }
export async function getPrivateMessages(options = {}) {
const { data } = await api.get('/messages/private', { params: options });
return data;
}
// Новые функции для работы с диалогами // Новые функции для работы с диалогами
export async function getConversations(userId) { export async function getConversations(userId) {
@@ -120,4 +134,36 @@ export async function getReadStatus() {
export async function deleteMessageHistory(userId) { export async function deleteMessageHistory(userId) {
const { data } = await api.delete(`/messages/delete-history/${userId}`); const { data } = await api.delete(`/messages/delete-history/${userId}`);
return data; 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;
} }

View File

@@ -30,6 +30,8 @@
:canSend="true" :canSend="true"
:canGenerateAI="false" :canGenerateAI="false"
:canSelectMessages="false" :canSelectMessages="false"
:isPrivateChat="true"
:currentUserId="currentUserId"
@send-message="handleSendMessage" @send-message="handleSendMessage"
@update:newMessage="val => chatNewMessage = val" @update:newMessage="val => chatNewMessage = val"
@update:attachments="val => chatAttachments = val" @update:attachments="val => chatAttachments = val"
@@ -44,12 +46,15 @@ import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import BaseLayout from '../components/BaseLayout.vue'; import BaseLayout from '../components/BaseLayout.vue';
import ChatInterface from '../components/ChatInterface.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 route = useRoute();
const router = useRouter(); const router = useRouter();
const { userId } = useAuthContext();
const adminId = computed(() => route.params.adminId); const adminId = computed(() => route.params.adminId);
const currentUserId = computed(() => userId.value);
const messages = ref([]); const messages = ref([]);
const chatAttachments = ref([]); const chatAttachments = ref([]);
const chatNewMessage = ref(''); const chatNewMessage = ref('');
@@ -60,12 +65,35 @@ async function loadMessages() {
try { try {
isLoadingMessages.value = true; 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); console.log('[AdminChatView] Загружено сообщений:', messages.value.length);
} catch (error) { } catch (error) {
console.error('[AdminChatView] Ошибка загрузки сообщений:', error); console.error('[AdminChatView] Ошибка загрузки сообщений:', error);
@@ -79,9 +107,12 @@ async function handleSendMessage({ message, attachments }) {
if (!message.trim() || !adminId.value) return; if (!message.trim() || !adminId.value) return;
try { 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 = ''; chatNewMessage.value = '';

View File

@@ -37,7 +37,7 @@
<div class="message-preview">{{ message.last_message || 'Нет сообщений' }}</div> <div class="message-preview">{{ message.last_message || 'Нет сообщений' }}</div>
<div class="message-date">{{ formatDate(message.last_message_at) }}</div> <div class="message-date">{{ formatDate(message.last_message_at) }}</div>
</div> </div>
<el-button type="primary" size="small" @click="openPersonalChat(message.id)"> <el-button type="primary" size="small" @click="openPersonalChat(message)">
Открыть Открыть
</el-button> </el-button>
</div> </div>
@@ -51,7 +51,7 @@ import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../components/BaseLayout.vue'; import BaseLayout from '../components/BaseLayout.vue';
import adminChatService from '../services/adminChatService.js'; import adminChatService from '../services/adminChatService.js';
import { usePermissions } from '@/composables/usePermissions'; import { usePermissions } from '@/composables/usePermissions';
import { getPrivateMessages } from '../services/messagesService'; import { getPrivateConversations } from '../services/messagesService';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -66,41 +66,43 @@ let ws = null;
async function fetchPersonalMessages() { async function fetchPersonalMessages() {
try { try {
isLoading.value = true; isLoading.value = true;
console.log('[PersonalMessagesView] Загружаем приватные сообщения...'); console.log('[PersonalMessagesView] Загружаем приватные чаты...');
// Загружаем приватные сообщения через новый API с пагинацией // Загружаем приватные чаты через новый API
const response = await getPrivateMessages({ limit: 100, offset: 0 }); const response = await getPrivateConversations();
console.log('[PersonalMessagesView] Загружено приватных сообщений:', response.messages?.length || 0); console.log('[PersonalMessagesView] Загружено приватных чатов:', response.conversations?.length || 0);
const privateMessages = response.success && response.messages ? response.messages : []; const conversations = response.success && response.conversations ? response.conversations : [];
// Группируем сообщения по отправителям для отображения списка бесед console.log('[PersonalMessagesView] Полученные conversations:', conversations);
const messageGroups = {};
privateMessages.forEach(msg => { // Проверяем, что у нас есть данные
const senderId = msg.sender_id || 'unknown'; if (!conversations || conversations.length === 0) {
if (!messageGroups[senderId]) { console.log('[PersonalMessagesView] Нет приватных чатов');
messageGroups[senderId] = { personalMessages.value = [];
id: senderId, newMessagesCount.value = 0;
name: `Админ ${senderId}`, return;
last_message: msg.content, }
last_message_at: msg.created_at,
messages: [] // Формируем список бесед
}; personalMessages.value = conversations.map(conv => {
} console.log('[PersonalMessagesView] Обрабатываем conversation:', conv);
messageGroups[senderId].messages.push(msg); return {
// Обновляем последнее сообщение id: conv.conversation_id,
if (new Date(msg.created_at) > new Date(messageGroups[senderId].last_message_at)) { conversation_id: conv.conversation_id,
messageGroups[senderId].last_message = msg.content; user_id: conv.user_id,
messageGroups[senderId].last_message_at = msg.created_at; 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; newMessagesCount.value = personalMessages.value.length;
console.log('[PersonalMessagesView] Сформировано бесед:', personalMessages.value.length); console.log('[PersonalMessagesView] Сформировано бесед:', personalMessages.value.length);
} catch (error) { } catch (error) {
console.error('[PersonalMessagesView] Ошибка загрузки приватных сообщений:', error); console.error('[PersonalMessagesView] Ошибка загрузки приватных чатов:', error);
personalMessages.value = []; personalMessages.value = [];
} finally { } finally {
isLoading.value = false; isLoading.value = false;
@@ -156,9 +158,19 @@ function disconnectWebSocket() {
} }
} }
function openPersonalChat(adminId) { function openPersonalChat(conversation) {
console.log('[PersonalMessagesView] Открываем приватный чат с админом:', adminId); console.log('[PersonalMessagesView] Открываем приватный чат:', conversation);
router.push({ name: 'admin-chat', params: { adminId } });
// Проверяем, что у нас есть 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() { function goBack() {