feat: новая функция
This commit is contained in:
@@ -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 || [];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'message',
|
'message',
|
||||||
message.sender_type === 'assistant' || message.role === 'assistant'
|
isPrivateChat
|
||||||
|
? (isCurrentUserMessage ? 'private-current-user' : 'private-other-user')
|
||||||
|
: message.sender_type === 'assistant' || message.role === 'assistant'
|
||||||
? 'ai-message'
|
? 'ai-message'
|
||||||
: message.sender_type === 'system' || message.role === 'system'
|
: message.sender_type === 'system' || message.role === 'system'
|
||||||
? 'system-message'
|
? 'system-message'
|
||||||
@@ -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>
|
||||||
@@ -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' }) {
|
||||||
|
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', {
|
const { data } = await api.post('/messages/send', {
|
||||||
recipientId,
|
recipientId,
|
||||||
content,
|
content,
|
||||||
messageType
|
messageType
|
||||||
});
|
});
|
||||||
return data;
|
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) {
|
||||||
@@ -121,3 +135,35 @@ 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;
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
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
|
||||||
};
|
};
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user