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

This commit is contained in:
2025-10-23 21:44:14 +03:00
parent 918da882d2
commit 6e21887c3b
17 changed files with 959 additions and 462 deletions

View File

@@ -208,22 +208,23 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re
// Получаем информацию о пользователе // Получаем информацию о пользователе
const users = await encryptedDb.getData('users', { id: userId }, 1); const users = await encryptedDb.getData('users', { id: userId }, 1);
// ✨ НОВОЕ: Валидация прав через adminLogicService // ✨ Используем централизованную проверку прав
const adminLogicService = require('../services/adminLogicService'); const { canSendMessage } = require('/app/shared/permissions');
const sessionUserId = req.session.userId; const sessionUserId = req.session.userId;
const targetUserId = userId; const targetUserId = userId;
const userAccessLevel = req.session.userAccessLevel || { level: 'user', tokenCount: 0, hasAccess: false }; const userRole = req.session.userAccessLevel?.level || 'user';
const canWrite = adminLogicService.canWriteToConversation({
userAccessLevel: userAccessLevel,
userId: sessionUserId,
conversationUserId: targetUserId
});
if (!canWrite) { // Получаем роль получателя
logger.warn(`[Chat] Пользователь ${sessionUserId} пытался писать в беседу ${targetUserId} без прав`); const recipientUser = users[0];
const recipientRole = recipientUser.role || 'user';
const permissionCheck = canSendMessage(userRole, recipientRole, sessionUserId, targetUserId);
if (!permissionCheck.canSend) {
logger.warn(`[Chat] Пользователь ${sessionUserId} (${userRole}) пытался писать в беседу ${targetUserId} (${recipientRole}) без прав: ${permissionCheck.errorMessage}`);
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
error: 'Нет прав для отправки сообщений в эту беседу' error: permissionCheck.errorMessage || 'Недостаточно прав для отправки сообщений'
}); });
} }
if (!users || users.length === 0) { if (!users || users.length === 0) {
@@ -327,10 +328,10 @@ router.get('/history', requireAuth, async (req, res) => {
try { try {
// Если нужен только подсчет // Если нужен только подсчет
if (countOnly) { if (countOnly) {
let countQuery = 'SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = $2'; let countQuery = 'SELECT COUNT(*) FROM messages WHERE user_id = $1 AND (message_type = $2 OR message_type = $3)';
let countParams = [userId, 'user_chat']; let countParams = [userId, 'user_chat', 'public'];
if (conversationId) { if (conversationId) {
countQuery += ' AND conversation_id = $3'; countQuery += ' AND conversation_id = $4';
countParams.push(conversationId); countParams.push(conversationId);
} }
const countResult = await db.getQuery()(countQuery, countParams); const countResult = await db.getQuery()(countQuery, countParams);
@@ -338,17 +339,28 @@ router.get('/history', requireAuth, async (req, res) => {
return res.json({ success: true, count: totalCount }); return res.json({ success: true, count: totalCount });
} }
// Загружаем сообщения через encryptedDb // Загружаем сообщения: ИИ сообщения + публичные сообщения от других пользователей
const whereConditions = { // Используем SQL запрос для правильной фильтрации
user_id: userId, const encryptionUtils = require('../utils/encryptionUtils');
message_type: 'user_chat' // Фильтруем только публичные сообщения const encryptionKey = encryptionUtils.getEncryptionKey();
};
if (conversationId) { const result = await db.getQuery()(
whereConditions.conversation_id = conversationId; `SELECT m.id, m.user_id, m.sender_id, m.conversation_id,
} decrypt_text(m.sender_type_encrypted, $2) as sender_type,
decrypt_text(m.content_encrypted, $2) as content,
// Изменяем логику: загружаем ПОСЛЕДНИЕ сообщения, а не с offset decrypt_text(m.channel_encrypted, $2) as channel,
const messages = await encryptedDb.getData('messages', whereConditions, limit, 'created_at DESC', 0); decrypt_text(m.role_encrypted, $2) as role,
decrypt_text(m.direction_encrypted, $2) as direction,
m.message_type, m.created_at
FROM messages m
WHERE m.user_id = $1
AND (m.message_type = 'user_chat' OR m.message_type = 'public')
ORDER BY m.created_at DESC
LIMIT $3`,
[userId, encryptionKey, limit]
);
const messages = result.rows;
// Переворачиваем массив для правильного порядка // Переворачиваем массив для правильного порядка
messages.reverse(); messages.reverse();

View File

@@ -39,18 +39,20 @@ router.get('/public', requireAuth, async (req, res) => {
// Публичные сообщения видны на главной странице пользователя // Публичные сообщения видны на главной странице пользователя
const targetUserId = userId || currentUserId; const targetUserId = userId || currentUserId;
// Если нужен только подсчет // Если нужен только подсчет
if (countOnly) { if (countOnly) {
const countResult = await db.getQuery()( const countResult = await db.getQuery()(
`SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'public'`, `SELECT COUNT(*) FROM messages WHERE message_type = 'public'
[targetUserId] AND ((user_id = $1 AND sender_id = $2) OR (user_id = $2 AND sender_id = $1))`,
[targetUserId, currentUserId]
); );
const totalCount = parseInt(countResult.rows[0].count, 10); const totalCount = parseInt(countResult.rows[0].count, 10);
return res.json({ success: true, count: totalCount, total: totalCount }); return res.json({ success: true, count: totalCount, total: totalCount });
} }
const result = await db.getQuery()( const result = await db.getQuery()(
`SELECT m.id, m.user_id, decrypt_text(m.sender_type_encrypted, $2) as sender_type, `SELECT m.id, m.user_id, m.sender_id, decrypt_text(m.sender_type_encrypted, $2) as sender_type,
decrypt_text(m.content_encrypted, $2) as content, decrypt_text(m.content_encrypted, $2) as content,
decrypt_text(m.channel_encrypted, $2) as channel, decrypt_text(m.channel_encrypted, $2) as channel,
decrypt_text(m.role_encrypted, $2) as role, decrypt_text(m.role_encrypted, $2) as role,
@@ -59,7 +61,8 @@ router.get('/public', requireAuth, async (req, res) => {
arm.last_read_at arm.last_read_at
FROM messages m FROM messages m
LEFT JOIN admin_read_messages arm ON arm.user_id = m.user_id AND arm.admin_id = $5 LEFT JOIN admin_read_messages arm ON arm.user_id = m.user_id AND arm.admin_id = $5
WHERE m.user_id = $1 AND m.message_type = 'public' WHERE m.message_type = 'public'
AND ((m.user_id = $1 AND m.sender_id = $5) OR (m.user_id = $5 AND m.sender_id = $1))
ORDER BY m.created_at DESC ORDER BY m.created_at DESC
LIMIT $3 OFFSET $4`, LIMIT $3 OFFSET $4`,
[targetUserId, encryptionKey, limit, offset, currentUserId] [targetUserId, encryptionKey, limit, offset, currentUserId]
@@ -67,8 +70,9 @@ router.get('/public', requireAuth, async (req, res) => {
// Получаем общее количество для пагинации // Получаем общее количество для пагинации
const countResult = await db.getQuery()( const countResult = await db.getQuery()(
`SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'public'`, `SELECT COUNT(*) FROM messages WHERE message_type = 'public'
[targetUserId] AND ((user_id = $1 AND sender_id = $2) OR (user_id = $2 AND sender_id = $1))`,
[targetUserId, currentUserId]
); );
const totalCount = parseInt(countResult.rows[0].count, 10); const totalCount = parseInt(countResult.rows[0].count, 10);
@@ -102,7 +106,7 @@ router.get('/private', requireAuth, async (req, res) => {
// Если нужен только подсчет // Если нужен только подсчет
if (countOnly) { if (countOnly) {
const countResult = await db.getQuery()( const countResult = await db.getQuery()(
`SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'private'`, `SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'admin_chat'`,
[currentUserId] [currentUserId]
); );
const totalCount = parseInt(countResult.rows[0].count, 10); const totalCount = parseInt(countResult.rows[0].count, 10);
@@ -120,17 +124,17 @@ router.get('/private', requireAuth, async (req, res) => {
arm.last_read_at arm.last_read_at
FROM messages m FROM messages m
LEFT JOIN admin_read_messages arm ON arm.user_id = m.user_id AND arm.admin_id = $5 LEFT JOIN admin_read_messages arm ON arm.user_id = m.user_id AND arm.admin_id = $5
WHERE m.user_id = $1 AND m.message_type = 'private' WHERE m.user_id = $1 AND m.message_type = 'admin_chat'
ORDER BY m.created_at DESC ORDER BY m.created_at DESC
LIMIT $3 OFFSET $4`, LIMIT $3 OFFSET $4`,
[currentUserId, encryptionKey, limit, offset, currentUserId] [currentUserId, encryptionKey, limit, offset, currentUserId]
); );
// Получаем общее количество для пагинации // Получаем общее количество для пагинации
const countResult = await db.getQuery()( const countResult = await db.getQuery()(
`SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'private'`, `SELECT COUNT(*) FROM messages WHERE user_id = $1 AND message_type = 'admin_chat'`,
[currentUserId] [currentUserId]
); );
const totalCount = parseInt(countResult.rows[0].count, 10); const totalCount = parseInt(countResult.rows[0].count, 10);
res.json({ res.json({
@@ -209,44 +213,7 @@ router.get('/read-status', async (req, res) => {
} }
}); });
// GET /api/conversations?userId=123 // УДАЛЕНО: Дублирующиеся endpoint'ы перенесены ниже
router.get('/conversations', async (req, res) => {
const userId = req.query.userId;
if (!userId) return res.status(400).json({ error: 'userId required' });
try {
const result = await db.getQuery()(
'SELECT * FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1',
[userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Conversation not found' });
}
res.json(result.rows[0]);
} catch (e) {
res.status(500).json({ error: 'DB error', details: e.message });
}
});
// POST /api/conversations - создать беседу для пользователя
router.post('/conversations', async (req, res) => {
const { userId, title } = req.body;
if (!userId) return res.status(400).json({ error: 'userId required' });
// Получаем ключ шифрования через унифицированную утилиту
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
try {
const conversationTitle = title || `Чат с пользователем ${userId}`;
const result = await db.getQuery()(
'INSERT INTO conversations (user_id, title_encrypted, created_at, updated_at) VALUES ($1, encrypt_text($2, $3), NOW(), NOW()) RETURNING *',
[userId, conversationTitle, encryptionKey]
);
res.json(result.rows[0]);
} catch (e) {
res.status(500).json({ error: 'DB error', details: e.message });
}
});
// Массовая рассылка сообщения во все каналы пользователя // Массовая рассылка сообщения во все каналы пользователя
// Массовая рассылка сообщений // Массовая рассылка сообщений
@@ -268,7 +235,7 @@ router.post('/broadcast', requireAuth, requirePermission(PERMISSIONS.BROADCAST),
}); });
if (!canBroadcast) { if (!canBroadcast) {
logger.warn(`[Messages] Пользователь ${req.session.userId} (роль: ${userRole}) пытался сделать broadcast без прав`); console.warn(`[Messages] Пользователь ${req.session.userId} (роль: ${userRole}) пытался сделать broadcast без прав`);
return res.status(403).json({ return res.status(403).json({
error: 'Только редакторы (editor) могут делать массовую рассылку' error: 'Только редакторы (editor) могут делать массовую рассылку'
}); });
@@ -287,15 +254,15 @@ router.post('/broadcast', requireAuth, requirePermission(PERMISSIONS.BROADCAST),
const identities = identitiesRes.rows; const identities = identitiesRes.rows;
// --- Найти или создать беседу (conversation) --- // --- Найти или создать беседу (conversation) ---
let conversationResult = await db.getQuery()( let conversationResult = await db.getQuery()(
'SELECT id, user_id, created_at, updated_at, decrypt_text(title_encrypted, $2) as title FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1', 'SELECT id, user_id, created_at, updated_at, title FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1',
[user_id, encryptionKey] [user_id, encryptionKey]
); );
let conversation; let conversation;
if (conversationResult.rows.length === 0) { if (conversationResult.rows.length === 0) {
const title = `Чат с пользователем ${user_id}`; const title = `Чат с пользователем ${user_id}`;
const newConv = await db.getQuery()( 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 *', 'INSERT INTO conversations (user_id, title, created_at, updated_at) VALUES ($1, $2, NOW(), NOW()) RETURNING *',
[user_id, title, encryptionKey] [user_id, title]
); );
conversation = newConv.rows[0]; conversation = newConv.rows[0];
} else { } else {
@@ -312,14 +279,14 @@ router.post('/broadcast', requireAuth, requirePermission(PERMISSIONS.BROADCAST),
await emailBot.sendEmail(email, 'Новое сообщение', content); await emailBot.sendEmail(email, 'Новое сообщение', content);
// Сохраняем в messages с conversation_id // Сохраняем в messages с conversation_id
await db.getQuery()( 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) `INSERT INTO messages (conversation_id, sender_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, user_id, role, direction, created_at)
VALUES ($1, $2, encrypt_text($3, $8), encrypt_text($4, $8), encrypt_text($5, $8), encrypt_text($6, $8), encrypt_text($7, $8), $9, NOW())`, VALUES ($1, $2, encrypt_text($3, $12), encrypt_text($4, $12), encrypt_text($5, $12), encrypt_text($6, $12), encrypt_text($7, $12), $8, $9, $10, $11, NOW())`,
[user_id, conversation.id, 'editor', content, 'email', 'user', 'out', encryptionKey, 'user_chat'] [conversation.id, req.session.userId, 'editor', content, 'email', 'user', 'out', 'user_chat', user_id, 'user', 'out', encryptionKey]
); );
results.push({ channel: 'email', status: 'sent' }); results.push({ channel: 'email', status: 'sent' });
sent = true; sent = true;
} else { } else {
logger.warn('[messages.js] Email Bot не инициализирован'); console.warn('[messages.js] Email Bot не инициализирован');
results.push({ channel: 'email', status: 'error', error: 'Bot not initialized' }); results.push({ channel: 'email', status: 'error', error: 'Bot not initialized' });
} }
} catch (err) { } catch (err) {
@@ -335,14 +302,14 @@ router.post('/broadcast', requireAuth, requirePermission(PERMISSIONS.BROADCAST),
const bot = telegramBot.getBot(); const bot = telegramBot.getBot();
await bot.telegram.sendMessage(telegram, content); await bot.telegram.sendMessage(telegram, content);
await db.getQuery()( 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) `INSERT INTO messages (conversation_id, sender_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, user_id, role, direction, created_at)
VALUES ($1, $2, encrypt_text($3, $8), encrypt_text($4, $8), encrypt_text($5, $8), encrypt_text($6, $8), encrypt_text($7, $8), $9, NOW())`, VALUES ($1, $2, encrypt_text($3, $12), encrypt_text($4, $12), encrypt_text($5, $12), encrypt_text($6, $12), encrypt_text($7, $12), $8, $9, $10, $11, NOW())`,
[user_id, conversation.id, 'editor', content, 'telegram', 'user', 'out', encryptionKey, 'user_chat'] [conversation.id, req.session.userId, 'editor', content, 'telegram', 'user', 'out', 'user_chat', user_id, 'user', 'out', encryptionKey]
); );
results.push({ channel: 'telegram', status: 'sent' }); results.push({ channel: 'telegram', status: 'sent' });
sent = true; sent = true;
} else { } else {
logger.warn('[messages.js] Telegram Bot не инициализирован'); console.warn('[messages.js] Telegram Bot не инициализирован');
results.push({ channel: 'telegram', status: 'error', error: 'Bot not initialized' }); results.push({ channel: 'telegram', status: 'error', error: 'Bot not initialized' });
} }
} catch (err) { } catch (err) {
@@ -354,9 +321,9 @@ router.post('/broadcast', requireAuth, requirePermission(PERMISSIONS.BROADCAST),
if (wallet) { if (wallet) {
// Здесь можно реализовать отправку через web3, если нужно // Здесь можно реализовать отправку через web3, если нужно
await db.getQuery()( 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) `INSERT INTO messages (conversation_id, sender_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, user_id, role, direction, created_at)
VALUES ($1, $2, encrypt_text($3, $9), encrypt_text($4, $9), encrypt_text($5, $9), encrypt_text($6, $9), encrypt_text($7, $9), $8, NOW())`, VALUES ($1, $2, encrypt_text($3, $12), encrypt_text($4, $12), encrypt_text($5, $12), encrypt_text($6, $12), encrypt_text($7, $12), $8, $9, $10, $11, NOW())`,
[user_id, conversation.id, 'editor', content, 'wallet', 'user', 'out', 'user_chat', encryptionKey] [conversation.id, req.session.userId, 'editor', content, 'wallet', 'user', 'out', 'user_chat', user_id, 'user', 'out', encryptionKey]
); );
results.push({ channel: 'wallet', status: 'saved' }); results.push({ channel: 'wallet', status: 'saved' });
sent = true; sent = true;
@@ -382,68 +349,78 @@ router.post('/send', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'messageType должен быть "public" или "private"' }); return res.status(400).json({ error: 'messageType должен быть "public" или "private"' });
} }
// Определяем recipientId в зависимости от типа сообщения
let recipientIdNum;
if (messageType === 'private') {
// Приватные сообщения всегда идут к редактору (ID = 1)
recipientIdNum = 1;
} else {
// Конвертируем recipientId в число для публичных сообщений
recipientIdNum = parseInt(recipientId);
if (isNaN(recipientIdNum)) {
return res.status(400).json({ error: 'recipientId должен быть числом' });
}
}
try { try {
// Получаем информацию об отправителе
const senderId = req.user.id; const senderId = req.user.id;
const senderRole = req.user.contact_type || req.user.role; const senderRole = req.user.role || req.user.userAccessLevel?.level || 'user';
console.log('[DEBUG] /messages/send: senderId:', senderId, 'senderRole:', senderRole);
// Получаем информацию о получателе // Получаем информацию о получателе
const recipientResult = await db.getQuery()( const recipientResult = await db.getQuery()(
'SELECT id, contact_type FROM users WHERE id = $1', 'SELECT id, role FROM users WHERE id = $1',
[recipientId] [recipientIdNum]
); );
if (recipientResult.rows.length === 0) { if (recipientResult.rows.length === 0) {
return res.status(404).json({ error: 'Получатель не найден' }); return res.status(404).json({ error: 'Получатель не найден' });
} }
const recipientRole = recipientResult.rows[0].contact_type; const recipientRole = recipientResult.rows[0].role;
console.log('[DEBUG] /messages/send: recipientId:', recipientIdNum, 'recipientRole:', recipientRole);
// Проверка прав согласно матрице разрешений // Используем централизованную проверку прав
const canSend = ( const { canSendMessage } = require('/app/shared/permissions');
// Editor может отправлять всем const permissionCheck = canSendMessage(senderRole, recipientRole, senderId, recipientIdNum);
(senderRole === 'editor') ||
// User и readonly могут отправлять только editor
((senderRole === 'user' || senderRole === 'readonly') && recipientRole === 'editor')
);
if (!canSend) { console.log('[DEBUG] /messages/send: canSend:', permissionCheck.canSend, 'senderRole:', senderRole, 'recipientRole:', recipientRole, 'error:', permissionCheck.errorMessage);
if (!permissionCheck.canSend) {
return res.status(403).json({ return res.status(403).json({
error: 'Недостаточно прав для отправки сообщения этому получателю' error: permissionCheck.errorMessage || 'Недостаточно прав для отправки сообщения этому получателю'
}); });
} }
// Получаем ключ шифрования // ✨ Используем unifiedMessageProcessor для унификации
const encryptionUtils = require('../utils/encryptionUtils'); const unifiedMessageProcessor = require('../services/unifiedMessageProcessor');
const encryptionKey = encryptionUtils.getEncryptionKey(); const identityService = require('../services/identity-service');
// Находим или создаем беседу // Получаем wallet идентификатор отправителя
let conversationResult = await db.getQuery()( const walletIdentity = await identityService.findIdentity(senderId, 'wallet');
'SELECT id FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC LIMIT 1', if (!walletIdentity) {
[recipientId] return res.status(403).json({
); error: 'Требуется подключение кошелька'
});
let conversationId;
if (conversationResult.rows.length === 0) {
const title = `Чат с пользователем ${recipientId}`;
const newConv = await db.getQuery()(
'INSERT INTO conversations (user_id, title_encrypted, created_at, updated_at) VALUES ($1, encrypt_text($2, $3), NOW(), NOW()) RETURNING id',
[recipientId, title, encryptionKey]
);
conversationId = newConv.rows[0].id;
} else {
conversationId = conversationResult.rows[0].id;
} }
// Сохраняем сообщение с типом const identifier = `wallet:${walletIdentity.provider_id}`;
const result = await db.getQuery()(
`INSERT INTO messages (user_id, conversation_id, sender_type_encrypted, content_encrypted, channel_encrypted, role_encrypted, direction_encrypted, message_type, created_at)
VALUES ($1, $2, encrypt_text($3, $8), encrypt_text($4, $8), encrypt_text($5, $8), encrypt_text($6, $8), encrypt_text($7, $8), $9, NOW()) RETURNING *`,
[recipientId, conversationId, 'editor', content, 'web', 'user', 'out', encryptionKey, messageType]
);
// Отправляем обновление через WebSocket // Обрабатываем через unifiedMessageProcessor
broadcastMessagesUpdate(); const result = await unifiedMessageProcessor.processMessage({
identifier: identifier,
content: content,
channel: 'web',
attachments: [],
conversationId: null, // unifiedMessageProcessor сам найдет/создаст беседу
recipientId: recipientIdNum,
userId: senderId,
metadata: {
messageType: messageType,
markAsRead: markAsRead
}
});
// Если нужно отметить как прочитанное // Если нужно отметить как прочитанное
if (markAsRead) { if (markAsRead) {
@@ -453,7 +430,7 @@ router.post('/send', requireAuth, async (req, res) => {
`INSERT INTO admin_read_messages (admin_id, user_id, last_read_at) `INSERT INTO admin_read_messages (admin_id, user_id, last_read_at)
VALUES ($1, $2, $3) VALUES ($1, $2, $3)
ON CONFLICT (admin_id, user_id) DO UPDATE SET last_read_at = EXCLUDED.last_read_at`, ON CONFLICT (admin_id, user_id) DO UPDATE SET last_read_at = EXCLUDED.last_read_at`,
[senderId, recipientId, lastReadAt] [senderId, recipientIdNum, lastReadAt]
); );
} catch (markError) { } catch (markError) {
console.warn('[WARNING] /send mark-read error:', markError); console.warn('[WARNING] /send mark-read error:', markError);
@@ -461,7 +438,7 @@ router.post('/send', requireAuth, async (req, res) => {
} }
} }
res.json({ success: true, message: result.rows[0] }); res.json({ success: true, message: result });
} catch (e) { } catch (e) {
console.error('[ERROR] /send:', e); console.error('[ERROR] /send:', e);
res.status(500).json({ error: 'DB error', details: e.message }); res.status(500).json({ error: 'DB error', details: e.message });
@@ -478,121 +455,60 @@ router.post('/private/send', requireAuth, async (req, res) => {
} }
try { try {
// Получаем информацию об отправителе и получателе const senderRole = req.user.role || req.user.userAccessLevel?.level || 'user';
const senderResult = await db.getQuery()(
'SELECT id, role FROM users WHERE id = $1',
[senderId]
);
// Получаем информацию о получателе
const recipientResult = await db.getQuery()( const recipientResult = await db.getQuery()(
'SELECT id, role FROM users WHERE id = $1', 'SELECT id, role FROM users WHERE id = $1',
[recipientId] [recipientId]
); );
if (senderResult.rows.length === 0) {
return res.status(404).json({ error: 'Отправитель не найден' });
}
if (recipientResult.rows.length === 0) { if (recipientResult.rows.length === 0) {
return res.status(404).json({ error: 'Получатель не найден' }); return res.status(404).json({ error: 'Получатель не найден' });
} }
const sender = senderResult.rows[0]; const recipientRole = recipientResult.rows[0].role;
const recipient = recipientResult.rows[0];
// Проверяем права: только к админам-редакторам // Используем централизованную проверку прав
if (recipient.role !== 'editor') { const { canSendMessage } = require('/app/shared/permissions');
const permissionCheck = canSendMessage(senderRole, recipientRole, senderId, recipientId);
if (!permissionCheck.canSend) {
return res.status(403).json({ return res.status(403).json({
error: 'Приватные сообщения можно отправлять только админам-редакторам' error: permissionCheck.errorMessage || 'Недостаточно прав для отправки приватного сообщения'
}); });
} }
// Получаем ключ шифрования // ✨ Используем unifiedMessageProcessor для унификации
const encryptionUtils = require('../utils/encryptionUtils'); const unifiedMessageProcessor = require('../services/unifiedMessageProcessor');
const encryptionKey = encryptionUtils.getEncryptionKey(); const identityService = require('../services/identity-service');
// Находим или создаем приватную беседу // Получаем wallet идентификатор отправителя
let conversationResult = await db.getQuery()( const walletIdentity = await identityService.findIdentity(senderId, 'wallet');
`SELECT id FROM conversations if (!walletIdentity) {
WHERE user_id = $1 AND conversation_type = 'private' return res.status(403).json({
ORDER BY updated_at DESC LIMIT 1`, error: 'Требуется подключение кошелька'
[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 identifier = `wallet:${walletIdentity.provider_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
]
);
// Обновляем время последнего обновления беседы // Обрабатываем через unifiedMessageProcessor
await db.getQuery()( // Для приватных сообщений recipientId всегда = 1 (редактор)
'UPDATE conversations SET updated_at = NOW() WHERE id = $1', const result = await unifiedMessageProcessor.processMessage({
[conversationId] identifier: identifier,
); content: content,
channel: 'web',
// Отправляем обновление через WebSocket attachments: [],
const { broadcastMessagesUpdate } = require('../wsHub'); conversationId: null, // unifiedMessageProcessor сам найдет/создаст беседу
broadcastMessagesUpdate(); recipientId: 1, // Приватные сообщения всегда к редактору
userId: senderId,
metadata: {}
});
res.json({ res.json({
success: true, success: true,
messageId: result.rows[0].id, message: result
conversationId: conversationId
}); });
} catch (error) { } catch (error) {
@@ -615,16 +531,16 @@ router.get('/private/conversations', requireAuth, async (req, res) => {
`SELECT DISTINCT `SELECT DISTINCT
c.id as conversation_id, c.id as conversation_id,
c.user_id, c.user_id,
decrypt_text(c.title_encrypted, $2) as title, c.title,
c.updated_at, c.updated_at,
COUNT(m.id) as message_count COUNT(m.id) as message_count
FROM conversations c FROM conversations c
INNER JOIN conversation_participants cp ON c.id = cp.conversation_id INNER JOIN conversation_participants cp ON c.id = cp.conversation_id
LEFT JOIN messages m ON c.id = m.conversation_id AND m.message_type = 'private' LEFT JOIN messages m ON c.id = m.conversation_id AND m.message_type = 'admin_chat'
WHERE cp.user_id = $1 AND c.conversation_type = 'private' WHERE cp.user_id = $1 AND c.conversation_type = 'private'
GROUP BY c.id, c.user_id, c.title_encrypted, c.updated_at GROUP BY c.id, c.user_id, c.title, c.updated_at
ORDER BY c.updated_at DESC`, ORDER BY c.updated_at DESC`,
[currentUserId, encryptionKey] [currentUserId]
); );
console.log('[DEBUG] /messages/private/conversations result:', result.rows); console.log('[DEBUG] /messages/private/conversations result:', result.rows);
@@ -640,55 +556,6 @@ router.get('/private/conversations', requireAuth, async (req, res) => {
} }
}); });
// GET /api/messages/private/:conversationId - получить историю приватного чата
router.get('/private/:conversationId', requireAuth, async (req, res) => {
const conversationId = req.params.conversationId;
const currentUserId = req.user.id;
try {
// Проверяем, что пользователь является участником этого чата
const participantCheck = await db.getQuery()(
'SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2',
[conversationId, currentUserId]
);
if (participantCheck.rows.length === 0) {
return res.status(403).json({ error: 'Доступ запрещен' });
}
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
// Получаем историю сообщений
const result = await db.getQuery()(
`SELECT
m.id,
m.sender_id,
m.user_id,
decrypt_text(m.sender_type_encrypted, $2) as sender_type,
decrypt_text(m.content_encrypted, $2) as content,
decrypt_text(m.channel_encrypted, $2) as channel,
decrypt_text(m.role_encrypted, $2) as role,
decrypt_text(m.direction_encrypted, $2) as direction,
m.message_type,
m.created_at
FROM messages m
WHERE m.conversation_id = $1 AND m.message_type = 'private'
ORDER BY m.created_at ASC`,
[conversationId, encryptionKey]
);
res.json({
success: true,
messages: result.rows
});
} catch (error) {
console.error('[ERROR] /messages/private/:conversationId:', error);
res.status(500).json({ error: 'DB error', details: error.message });
}
});
// GET /api/messages/private/unread-count - получить количество непрочитанных приватных сообщений // GET /api/messages/private/unread-count - получить количество непрочитанных приватных сообщений
router.get('/private/unread-count', requireAuth, async (req, res) => { router.get('/private/unread-count', requireAuth, async (req, res) => {
const currentUserId = req.user.id; const currentUserId = req.user.id;
@@ -702,7 +569,7 @@ router.get('/private/unread-count', requireAuth, async (req, res) => {
INNER JOIN conversation_participants cp ON c.id = cp.conversation_id INNER JOIN conversation_participants cp ON c.id = cp.conversation_id
WHERE cp.user_id = $1 WHERE cp.user_id = $1
AND c.conversation_type = 'private' AND c.conversation_type = 'private'
AND m.message_type = 'private' AND m.message_type = 'admin_chat'
AND m.user_id = $1 -- сообщения адресованные текущему пользователю AND m.user_id = $1 -- сообщения адресованные текущему пользователю
AND m.sender_id != $1 -- исключаем собственные сообщения AND m.sender_id != $1 -- исключаем собственные сообщения
AND NOT EXISTS ( AND NOT EXISTS (
@@ -767,6 +634,55 @@ router.post('/private/mark-read', requireAuth, async (req, res) => {
} }
}); });
// GET /api/messages/private/:conversationId - получить историю приватного чата
router.get('/private/:conversationId', requireAuth, async (req, res) => {
const conversationId = req.params.conversationId;
const currentUserId = req.user.id;
try {
// Проверяем, что пользователь является участником этого чата
const participantCheck = await db.getQuery()(
'SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2',
[conversationId, currentUserId]
);
if (participantCheck.rows.length === 0) {
return res.status(403).json({ error: 'Доступ запрещен' });
}
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
// Получаем историю сообщений
const result = await db.getQuery()(
`SELECT
m.id,
m.sender_id,
m.user_id,
decrypt_text(m.sender_type_encrypted, $2) as sender_type,
decrypt_text(m.content_encrypted, $2) as content,
decrypt_text(m.channel_encrypted, $2) as channel,
decrypt_text(m.role_encrypted, $2) as role,
decrypt_text(m.direction_encrypted, $2) as direction,
m.message_type,
m.created_at
FROM messages m
WHERE m.conversation_id = $1 AND m.message_type = 'admin_chat'
ORDER BY m.created_at ASC`,
[conversationId, encryptionKey]
);
res.json({
success: true,
messages: result.rows
});
} catch (error) {
console.error('[ERROR] /messages/private/:conversationId:', error);
res.status(500).json({ error: 'DB error', details: error.message });
}
});
// GET /api/messages/conversations?userId=123 - получить диалоги пользователя // 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;
@@ -794,9 +710,9 @@ router.post('/conversations', requireAuth, async (req, res) => {
try { try {
const result = await db.getQuery()( const result = await db.getQuery()(
`INSERT INTO conversations (user_id, title_encrypted, created_at, updated_at) `INSERT INTO conversations (user_id, title, created_at, updated_at)
VALUES ($1, encrypt_text($2, $3), NOW(), NOW()) RETURNING *`, VALUES ($1, $2, NOW(), NOW()) RETURNING *`,
[userId, title || 'Новый диалог', encryptionKey] [userId, title || 'Новый диалог']
); );
res.json({ success: true, conversation: result.rows[0] }); res.json({ success: true, conversation: result.rows[0] });
} catch (e) { } catch (e) {

View File

@@ -16,7 +16,7 @@ const db = require('../db');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { requireAuth } = require('../middleware/auth'); const { requireAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
const { PERMISSIONS } = require('../shared/permissions'); const { PERMISSIONS, ROLES } = require('../shared/permissions');
const { deleteUserById } = require('../services/userDeleteService'); const { deleteUserById } = require('../services/userDeleteService');
const { broadcastContactsUpdate } = require('../wsHub'); const { broadcastContactsUpdate } = require('../wsHub');
// const userService = require('../services/userService'); // const userService = require('../services/userService');
@@ -95,7 +95,6 @@ router.get('/', requireAuth, async (req, res, next) => {
// Фильтрация для USER - видит только editor админов и себя // Фильтрация для USER - видит только editor админов и себя
if (userRole === 'user') { if (userRole === 'user') {
const { ROLES } = require('/app/shared/permissions');
where.push(`(u.role = '${ROLES.EDITOR}' OR u.id = $${idx++})`); where.push(`(u.role = '${ROLES.EDITOR}' OR u.id = $${idx++})`);
params.push(req.user.id); params.push(req.user.id);
} }
@@ -375,11 +374,12 @@ router.post('/mark-contact-read', async (req, res) => {
if (req.user?.userAccessLevel) { if (req.user?.userAccessLevel) {
// Используем новую систему ролей // Используем новую систему ролей
const { ROLES } = require('/app/shared/permissions'); if (req.user.userAccessLevel.level === ROLES.READONLY || req.user.userAccessLevel.level === 'readonly') {
if (req.user.userAccessLevel.level === ROLES.READONLY) {
userRole = ROLES.READONLY; userRole = ROLES.READONLY;
} else if (req.user.userAccessLevel.level === ROLES.EDITOR) { } else if (req.user.userAccessLevel.level === ROLES.EDITOR || req.user.userAccessLevel.level === 'editor') {
userRole = ROLES.EDITOR; userRole = ROLES.EDITOR;
} else if (req.user.userAccessLevel.level === ROLES.USER || req.user.userAccessLevel.level === 'user') {
userRole = ROLES.USER;
} }
} else if (req.user?.id) { } else if (req.user?.id) {
// Fallback для старой системы // Fallback для старой системы
@@ -760,7 +760,6 @@ router.post('/', async (req, res) => {
const encryptionKey = encryptionUtils.getEncryptionKey(); const encryptionKey = encryptionUtils.getEncryptionKey();
// Используем централизованную систему ролей // Используем централизованную систему ролей
const { ROLES } = require('/app/shared/permissions');
try { try {
const result = await db.getQuery()( const result = await db.getQuery()(
@@ -824,7 +823,6 @@ router.post('/import', requireAuth, async (req, res) => {
} }
} else { } else {
// Создаём нового пользователя с централизованной ролью // Создаём нового пользователя с централизованной ролью
const { ROLES } = require('/app/shared/permissions');
const ins = await dbq('INSERT INTO users (first_name_encrypted, last_name_encrypted, role, created_at) VALUES (encrypt_text($1, $4), encrypt_text($2, $4), $3, NOW()) RETURNING id', [first_name, last_name, ROLES.USER, encryptionKey]); const ins = await dbq('INSERT INTO users (first_name_encrypted, last_name_encrypted, role, created_at) VALUES (encrypt_text($1, $4), encrypt_text($2, $4), $3, NOW()) RETURNING id', [first_name, last_name, ROLES.USER, encryptionKey]);
userId = ins.rows[0].id; userId = ins.rows[0].id;
added++; added++;

View File

@@ -449,33 +449,36 @@ class UniversalGuestService {
await db.getQuery()( await db.getQuery()(
`INSERT INTO messages ( `INSERT INTO messages (
user_id,
conversation_id, conversation_id,
sender_id,
sender_type_encrypted, sender_type_encrypted,
content_encrypted, content_encrypted,
channel_encrypted, channel_encrypted,
role_encrypted, role_encrypted,
direction_encrypted, direction_encrypted,
attachment_filename_encrypted, attachment_filename,
attachment_mimetype_encrypted, attachment_mimetype,
attachment_size, attachment_size,
attachment_data, attachment_data,
message_type, message_type,
user_id,
role,
direction,
created_at created_at
) VALUES ( ) VALUES (
$1, $2, $1, $2,
encrypt_text($3, $14), encrypt_text($3, $17),
encrypt_text($4, $14), encrypt_text($4, $17),
encrypt_text($5, $14), encrypt_text($5, $17),
encrypt_text($6, $14), encrypt_text($6, $17),
encrypt_text($7, $14), encrypt_text($7, $17),
encrypt_text($8, $14), $8, $9, $10, $11,
encrypt_text($9, $14), $12, $13, $14, $15,
$10, $11, $12, $13 $16
)`, )`,
[ [
userId,
conversationId, conversationId,
userId, // sender_id
senderType, senderType,
msg.content, msg.content,
msg.channel, msg.channel,
@@ -485,7 +488,10 @@ class UniversalGuestService {
msg.attachment_mimetype, msg.attachment_mimetype,
msg.attachment_size, msg.attachment_size,
msg.attachment_data, msg.attachment_data,
'public', // message_type для мигрированных сообщений 'user_chat', // message_type для мигрированных сообщений (личный чат с ИИ)
userId, // user_id
role, // role (незашифрованное)
direction, // direction (незашифрованное)
msg.created_at, msg.created_at,
encryptionKey encryptionKey
] ]

View File

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

View File

@@ -30,12 +30,12 @@ async function getOrCreateConversation(userId, title = 'Новая беседа'
// Ищем существующую активную беседу // Ищем существующую активную беседу
const { rows: existing } = await db.getQuery()( const { rows: existing } = await db.getQuery()(
`SELECT id, user_id, decrypt_text(title_encrypted, $2) as title, created_at, updated_at `SELECT id, user_id, title, created_at, updated_at
FROM conversations FROM conversations
WHERE user_id = $1 WHERE user_id = $1
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT 1`, LIMIT 1`,
[userId, encryptionKey] [userId]
); );
if (existing.length > 0) { if (existing.length > 0) {
@@ -44,10 +44,10 @@ async function getOrCreateConversation(userId, title = 'Новая беседа'
// Создаем новую беседу // Создаем новую беседу
const { rows: newConv } = await db.getQuery()( const { rows: newConv } = await db.getQuery()(
`INSERT INTO conversations (user_id, title_encrypted) `INSERT INTO conversations (user_id, title)
VALUES ($1, encrypt_text($2, $3)) VALUES ($1, $2)
RETURNING id, user_id, decrypt_text(title_encrypted, $3) as title, created_at, updated_at`, RETURNING id, user_id, title, created_at, updated_at`,
[userId, title, encryptionKey] [userId, title]
); );
logger.info('[ConversationService] Создана новая беседа:', newConv[0].id); logger.info('[ConversationService] Создана новая беседа:', newConv[0].id);
@@ -59,6 +59,60 @@ async function getOrCreateConversation(userId, title = 'Новая беседа'
} }
} }
/**
* Получить или создать публичную беседу между двумя пользователями
* @param {number} userId1 - ID первого пользователя
* @param {number} userId2 - ID второго пользователя
* @returns {Promise<Object>}
*/
async function getOrCreatePublicConversation(userId1, userId2) {
try {
// Ищем существующую публичную беседу между этими пользователями
const { rows: existing } = await db.getQuery()(
`SELECT c.id, c.user_id, c.title, c.created_at, c.updated_at, c.conversation_type
FROM conversations c
INNER JOIN conversation_participants cp1 ON c.id = cp1.conversation_id
INNER JOIN conversation_participants cp2 ON c.id = cp2.conversation_id
WHERE c.conversation_type = 'public_chat'
AND cp1.user_id = $1 AND cp2.user_id = $2
ORDER BY c.created_at DESC
LIMIT 1`,
[userId1, userId2]
);
if (existing.length > 0) {
return existing[0];
}
// Создаем новую публичную беседу
const { rows: newConv } = await db.getQuery()(
`INSERT INTO conversations (user_id, title, conversation_type)
VALUES ($1, $2, 'public_chat')
RETURNING id, user_id, title, created_at, updated_at, conversation_type`,
[userId1, `Публичная беседа ${userId1}-${userId2}`]
);
const conversation = newConv[0];
// Добавляем участников
await db.getQuery()(
`INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`,
[conversation.id, userId1]
);
await db.getQuery()(
`INSERT INTO conversation_participants (conversation_id, user_id) VALUES ($1, $2)`,
[conversation.id, userId2]
);
logger.info('[ConversationService] Создана публичная беседа:', conversation.id);
return conversation;
} catch (error) {
logger.error('[ConversationService] Ошибка создания публичной беседы:', error);
throw error;
}
}
/** /**
* Получить беседу по ID * Получить беседу по ID
* @param {number} conversationId - ID беседы * @param {number} conversationId - ID беседы
@@ -69,10 +123,10 @@ async function getConversationById(conversationId) {
const encryptionKey = encryptionUtils.getEncryptionKey(); const encryptionKey = encryptionUtils.getEncryptionKey();
const { rows } = await db.getQuery()( const { rows } = await db.getQuery()(
`SELECT id, user_id, decrypt_text(title_encrypted, $2) as title, created_at, updated_at `SELECT id, user_id, title, created_at, updated_at
FROM conversations FROM conversations
WHERE id = $1`, WHERE id = $1`,
[conversationId, encryptionKey] [conversationId]
); );
return rows.length > 0 ? rows[0] : null; return rows.length > 0 ? rows[0] : null;
@@ -93,11 +147,11 @@ async function getUserConversations(userId) {
const encryptionKey = encryptionUtils.getEncryptionKey(); const encryptionKey = encryptionUtils.getEncryptionKey();
const { rows } = await db.getQuery()( const { rows } = await db.getQuery()(
`SELECT id, user_id, decrypt_text(title_encrypted, $2) as title, created_at, updated_at `SELECT id, user_id, title, created_at, updated_at
FROM conversations FROM conversations
WHERE user_id = $1 WHERE user_id = $1
ORDER BY updated_at DESC`, ORDER BY updated_at DESC`,
[userId, encryptionKey] [userId]
); );
return rows; return rows;
@@ -164,10 +218,10 @@ async function updateConversationTitle(conversationId, userId, newTitle) {
const { rows } = await db.getQuery()( const { rows } = await db.getQuery()(
`UPDATE conversations `UPDATE conversations
SET title_encrypted = encrypt_text($3, $4), updated_at = NOW() SET title = $3, updated_at = NOW()
WHERE id = $1 AND user_id = $2 WHERE id = $1 AND user_id = $2
RETURNING id, user_id, decrypt_text(title_encrypted, $4) as title, created_at, updated_at`, RETURNING id, user_id, title, created_at, updated_at`,
[conversationId, userId, newTitle, encryptionKey] [conversationId, userId, newTitle]
); );
return rows.length > 0 ? rows[0] : null; return rows.length > 0 ? rows[0] : null;
@@ -180,6 +234,7 @@ async function updateConversationTitle(conversationId, userId, newTitle) {
module.exports = { module.exports = {
getOrCreateConversation, getOrCreateConversation,
getOrCreatePublicConversation,
getConversationById, getConversationById,
getUserConversations, getUserConversations,
touchConversation, touchConversation,

View File

@@ -18,6 +18,60 @@ const conversationService = require('./conversationService');
const adminLogicService = require('./adminLogicService'); const adminLogicService = require('./adminLogicService');
const universalGuestService = require('./UniversalGuestService'); const universalGuestService = require('./UniversalGuestService');
const identityService = require('./identity-service'); const identityService = require('./identity-service');
/**
* Определить тип сообщения по контексту
* @param {number|null} recipientId - ID получателя
* @param {number} userId - ID отправителя
* @param {boolean} isAdminSender - Является ли отправитель админом
* @returns {string} - Тип сообщения: 'user_chat', 'admin_chat', 'public'
*/
function determineMessageType(recipientId, userId, isAdminSender) {
// 1. Личный чат с ИИ (recipientId не указан или равен userId)
if (!recipientId || recipientId === userId) {
return 'user_chat';
}
// 2. Приватное сообщение к редактору (recipientId = 1)
if (recipientId === 1) {
return 'admin_chat';
}
// 3. Публичное сообщение между пользователями
return 'public';
}
/**
* Определить тип беседы
* @param {string} messageType - Тип сообщения
* @param {number|null} recipientId - ID получателя
* @param {number} userId - ID отправителя
* @returns {string} - Тип беседы: 'user_chat', 'private', 'public'
*/
function determineConversationType(messageType, recipientId, userId) {
switch (messageType) {
case 'user_chat':
return 'user_chat'; // Личная беседа с ИИ
case 'admin_chat':
return 'private'; // Приватная беседа с редактором
case 'public':
return 'public_chat'; // Публичная беседа между пользователями
default:
return 'user_chat';
}
}
/**
* Определить, нужно ли генерировать AI ответ
* @param {string} messageType - Тип сообщения
* @param {number|null} recipientId - ID получателя
* @param {number} userId - ID отправителя
* @returns {boolean}
*/
function shouldGenerateAiReply(messageType, recipientId, userId) {
// ИИ отвечает только в личных чатах
return messageType === 'user_chat';
}
const { broadcastMessagesUpdate } = require('../wsHub'); const { broadcastMessagesUpdate } = require('../wsHub');
// НОВАЯ СИСТЕМА РОЛЕЙ: используем shared/permissions.js // НОВАЯ СИСТЕМА РОЛЕЙ: используем shared/permissions.js
const { hasPermission, ROLES, PERMISSIONS } = require('/app/shared/permissions'); const { hasPermission, ROLES, PERMISSIONS } = require('/app/shared/permissions');
@@ -92,24 +146,39 @@ async function processMessage(messageData) {
// НОВАЯ СИСТЕМА РОЛЕЙ: определяем права через новую систему // НОВАЯ СИСТЕМА РОЛЕЙ: определяем права через новую систему
const isAdmin = userRole === ROLES.EDITOR || userRole === ROLES.READONLY; const isAdmin = userRole === ROLES.EDITOR || userRole === ROLES.READONLY;
// 4. Определяем нужно ли генерировать AI ответ // 4. Определяем тип сообщения по контексту
const shouldGenerateAi = adminLogicService.shouldGenerateAiReply({ const messageType = determineMessageType(recipientId, userId, isAdmin);
senderType: isAdmin ? 'editor' : 'user',
userId: userId, // 5. Определяем нужно ли генерировать AI ответ
recipientId: recipientId || userId, const shouldGenerateAi = shouldGenerateAiReply(messageType, recipientId, userId);
channel: channel
});
logger.info('[UnifiedMessageProcessor] Генерация AI:', { shouldGenerateAi, userRole, isAdmin }); logger.info('[UnifiedMessageProcessor] Генерация AI:', { shouldGenerateAi, userRole, isAdmin });
// 5. Получаем или создаем беседу // 6. Получаем или создаем беседу с правильным типом
let conversation; let conversation;
const conversationType = determineConversationType(messageType, recipientId, userId);
if (inputConversationId) { if (inputConversationId) {
conversation = await conversationService.getConversationById(inputConversationId); conversation = await conversationService.getConversationById(inputConversationId);
} }
if (!conversation) { if (!conversation) {
conversation = await conversationService.getOrCreateConversation(userId, 'Беседа'); // Для публичных сообщений создаем беседу между пользователями
if (messageType === 'public') {
conversation = await conversationService.getOrCreatePublicConversation(userId, recipientId);
} else {
// Для личных и админских чатов используем стандартную логику
conversation = await conversationService.getOrCreateConversation(userId, 'Беседа');
}
// Обновляем тип беседы в БД, если он не соответствует
if (conversation.conversation_type !== conversationType) {
await db.getQuery()(
'UPDATE conversations SET conversation_type = $1 WHERE id = $2',
[conversationType, conversation.id]
);
conversation.conversation_type = conversationType;
}
} }
const conversationId = conversation.id; const conversationId = conversation.id;
@@ -133,34 +202,38 @@ async function processMessage(messageData) {
const { rows } = await db.getQuery()( const { rows } = await db.getQuery()(
`INSERT INTO messages ( `INSERT INTO messages (
user_id,
conversation_id, conversation_id,
sender_id,
sender_type_encrypted, sender_type_encrypted,
content_encrypted, content_encrypted,
channel_encrypted, channel_encrypted,
role_encrypted, role_encrypted,
direction_encrypted, direction_encrypted,
attachment_filename_encrypted, attachment_filename,
attachment_mimetype_encrypted, attachment_mimetype,
attachment_size, attachment_size,
attachment_data, attachment_data,
message_type, message_type,
user_id,
role,
direction,
created_at created_at
) VALUES ( ) VALUES (
$1, $2, $1, $2,
encrypt_text($3, $13), encrypt_text($3, $16),
encrypt_text($4, $13), encrypt_text($4, $16),
encrypt_text($5, $13), encrypt_text($5, $16),
encrypt_text($6, $13), encrypt_text($6, $16),
encrypt_text($7, $13), encrypt_text($7, $16),
encrypt_text($8, $13), $8,
encrypt_text($9, $13), $9,
$10, $11, $12, $10, $11, $12,
$13, $14, $15,
NOW() NOW()
) RETURNING id`, ) RETURNING id`,
[ [
userId,
conversationId, conversationId,
userId, // sender_id
isAdmin ? 'editor' : 'user', isAdmin ? 'editor' : 'user',
content, content,
channel, channel,
@@ -170,7 +243,10 @@ async function processMessage(messageData) {
attachment_mimetype, attachment_mimetype,
attachment_size, attachment_size,
attachment_data, attachment_data,
'user_chat', // message_type messageType, // message_type
recipientId || userId, // user_id (получатель для публичных сообщений)
'user', // role (незашифрованное)
'incoming', // direction (незашифрованное)
encryptionKey encryptionKey
] ]
); );
@@ -220,34 +296,40 @@ async function processMessage(messageData) {
// Сохраняем ответ AI // Сохраняем ответ AI
const { rows: aiMessageRows } = await db.getQuery()( const { rows: aiMessageRows } = await db.getQuery()(
`INSERT INTO messages ( `INSERT INTO messages (
user_id,
conversation_id, conversation_id,
sender_id,
sender_type_encrypted, sender_type_encrypted,
content_encrypted, content_encrypted,
channel_encrypted, channel_encrypted,
role_encrypted, role_encrypted,
direction_encrypted, direction_encrypted,
message_type, message_type,
user_id,
role,
direction,
created_at created_at
) VALUES ( ) VALUES (
$1, $2, $1, $2,
encrypt_text($3, $9), encrypt_text($3, $12),
encrypt_text($4, $9), encrypt_text($4, $12),
encrypt_text($5, $9), encrypt_text($5, $12),
encrypt_text($6, $9), encrypt_text($6, $12),
encrypt_text($7, $9), encrypt_text($7, $12),
$8, $8, $9, $10, $11,
NOW() NOW()
) RETURNING id`, ) RETURNING id`,
[ [
userId,
conversationId, conversationId,
userId, // sender_id
'assistant', 'assistant',
aiResponse.response, aiResponse.response,
channel, channel,
'assistant', 'assistant',
'outgoing', 'outgoing',
'user_chat', messageType,
userId, // user_id
'assistant', // role (незашифрованное)
'outgoing', // direction (незашифрованное)
encryptionKey encryptionKey
] ]
); );

View File

@@ -89,13 +89,7 @@ async function deleteUserById(userId) {
); );
console.log('[DELETE] Удалено user_tag_links:', resTagLinks.rows.length); console.log('[DELETE] Удалено user_tag_links:', resTagLinks.rows.length);
// 9. Удаляем global_read_status // 9. global_read_status - таблица не существует, пропускаем
console.log('[DELETE] Начинаем удаление global_read_status для userId:', userId);
const resReadStatus = await db.getQuery()(
'DELETE FROM global_read_status WHERE user_id = $1 RETURNING user_id',
[userId]
);
console.log('[DELETE] Удалено global_read_status:', resReadStatus.rows.length);
// 10. Удаляем самого пользователя // 10. Удаляем самого пользователя
console.log('[DELETE] Начинаем удаление пользователя из users:', userId); console.log('[DELETE] Начинаем удаление пользователя из users:', userId);

248
database_schema_check.md Normal file
View File

@@ -0,0 +1,248 @@
# Проверка схемы базы данных
## Таблица `users`
```sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(255),
email VARCHAR(255) UNIQUE,
address VARCHAR(255) UNIQUE,
first_name_encrypted TEXT,
last_name_encrypted TEXT,
status VARCHAR(50) DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
role user_role DEFAULT 'user',
first_name VARCHAR(255),
last_name VARCHAR(255),
preferred_language JSONB,
is_blocked BOOLEAN DEFAULT false,
blocked_at TIMESTAMP
);
```
**Колонки:**
- `id` - SERIAL PRIMARY KEY
- `username` - VARCHAR(255)
- `email` - VARCHAR(255) UNIQUE
- `address` - VARCHAR(255) UNIQUE
- `first_name_encrypted` - TEXT (зашифрованное)
- `last_name_encrypted` - TEXT (зашифрованное)
- `status` - VARCHAR(50) DEFAULT 'active'
- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- `updated_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- `role` - user_role DEFAULT 'user'
- `first_name` - VARCHAR(255) (незашифрованное)
- `last_name` - VARCHAR(255) (незашифрованное)
- `preferred_language` - JSONB
- `is_blocked` - BOOLEAN DEFAULT false
- `blocked_at` - TIMESTAMP
## Таблица `conversations`
```sql
CREATE TABLE conversations (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
conversation_type VARCHAR(50) DEFAULT 'user_chat'
);
```
**Колонки:**
- `id` - SERIAL PRIMARY KEY
- `user_id` - INTEGER REFERENCES users(id)
- `title` - VARCHAR(255) (НЕ зашифрованное!)
- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- `updated_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- `conversation_type` - VARCHAR(50) DEFAULT 'user_chat'
## Таблица `messages`
```sql
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
conversation_id INTEGER REFERENCES conversations(id) ON DELETE CASCADE,
sender_type_encrypted TEXT NOT NULL,
sender_id INTEGER,
content_encrypted TEXT,
channel_encrypted TEXT NOT NULL,
role_encrypted TEXT NOT NULL DEFAULT 'user',
direction_encrypted TEXT,
metadata JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
tokens_used INTEGER DEFAULT 0,
is_processed BOOLEAN DEFAULT false,
role VARCHAR(20) NOT NULL DEFAULT 'user',
attachment_filename TEXT,
attachment_mimetype TEXT,
attachment_size BIGINT,
attachment_data BYTEA,
direction VARCHAR(8),
message_type VARCHAR(20) DEFAULT 'public'
);
```
**Колонки:**
- `id` - SERIAL PRIMARY KEY
- `conversation_id` - INTEGER REFERENCES conversations(id)
- `sender_type_encrypted` - TEXT NOT NULL (зашифрованное)
- `sender_id` - INTEGER
- `content_encrypted` - TEXT (зашифрованное)
- `channel_encrypted` - TEXT NOT NULL (зашифрованное)
- `role_encrypted` - TEXT NOT NULL DEFAULT 'user' (зашифрованное)
- `direction_encrypted` - TEXT (зашифрованное)
- `metadata` - JSONB
- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- `user_id` - INTEGER REFERENCES users(id)
- `tokens_used` - INTEGER DEFAULT 0
- `is_processed` - BOOLEAN DEFAULT false
- `role` - VARCHAR(20) NOT NULL DEFAULT 'user' (НЕ зашифрованное!)
- `attachment_filename` - TEXT
- `attachment_mimetype` - TEXT
- `attachment_size` - BIGINT
- `attachment_data` - BYTEA
- `direction` - VARCHAR(8) (НЕ зашифрованное!)
- `message_type` - VARCHAR(20) DEFAULT 'public'
**Триггеры:**
- `trg_set_message_user_id` - автоматически устанавливает user_id
## Таблица `conversation_participants`
```sql
CREATE TABLE conversation_participants (
id SERIAL PRIMARY KEY,
conversation_id INTEGER REFERENCES conversations(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(conversation_id, user_id)
);
```
**Колонки:**
- `id` - SERIAL PRIMARY KEY
- `conversation_id` - INTEGER REFERENCES conversations(id)
- `user_id` - INTEGER REFERENCES users(id)
- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP
**Индексы:**
- PRIMARY KEY на `id`
- UNIQUE CONSTRAINT на `(conversation_id, user_id)`
- Индекс на `conversation_id`
- Индекс на `user_id`
## Таблица `admin_read_messages`
```sql
CREATE TABLE admin_read_messages (
admin_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
last_read_at TIMESTAMP NOT NULL,
PRIMARY KEY (admin_id, user_id)
);
```
**Колонки:**
- `admin_id` - INTEGER NOT NULL REFERENCES users(id) (админ)
- `user_id` - INTEGER NOT NULL REFERENCES users(id) (пользователь)
- `last_read_at` - TIMESTAMP NOT NULL (время последнего прочтения)
**Индексы:**
- PRIMARY KEY на `(admin_id, user_id)`
## Таблица `admin_read_contacts`
```sql
CREATE TABLE admin_read_contacts (
admin_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
contact_id TEXT NOT NULL,
read_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (admin_id, contact_id)
);
```
**Колонки:**
- `admin_id` - INTEGER NOT NULL REFERENCES users(id) (админ)
- `contact_id` - TEXT NOT NULL (ID контакта)
- `read_at` - TIMESTAMP NOT NULL DEFAULT NOW() (время прочтения)
**Индексы:**
- PRIMARY KEY на `(admin_id, contact_id)`
- Индекс на `admin_id`
- Индекс на `contact_id`
## Таблица `user_identities`
```sql
CREATE TABLE user_identities (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
provider_encrypted TEXT NOT NULL,
provider_id_encrypted TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
**Колонки:**
- `id` - SERIAL PRIMARY KEY
- `user_id` - INTEGER REFERENCES users(id) (пользователь)
- `provider_encrypted` - TEXT NOT NULL (зашифрованный провайдер)
- `provider_id_encrypted` - TEXT NOT NULL (зашифрованный ID провайдера)
- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP
**Индексы:**
- PRIMARY KEY на `id`
- Индекс на `user_id`
## Таблица `user_preferences`
```sql
CREATE TABLE user_preferences (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
preference_key VARCHAR(50) NOT NULL,
preference_value TEXT,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(user_id, preference_key)
);
```
**Колонки:**
- `id` - SERIAL PRIMARY KEY
- `user_id` - INTEGER NOT NULL REFERENCES users(id) (пользователь)
- `preference_key` - VARCHAR(50) NOT NULL (ключ настройки)
- `preference_value` - TEXT (значение настройки)
- `metadata` - JSONB DEFAULT '{}' (метаданные)
- `created_at` - TIMESTAMP NOT NULL DEFAULT NOW()
- `updated_at` - TIMESTAMP NOT NULL DEFAULT NOW()
**Индексы:**
- PRIMARY KEY на `id`
- Индекс на `user_id`
- UNIQUE CONSTRAINT на `(user_id, preference_key)`
## Таблица `user_tag_links`
```sql
CREATE TABLE user_tag_links (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES user_rows(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, tag_id)
);
```
**Колонки:**
- `id` - SERIAL PRIMARY KEY
- `user_id` - INTEGER NOT NULL REFERENCES users(id) (пользователь)
- `tag_id` - INTEGER NOT NULL REFERENCES user_rows(id) (тег)
- `created_at` - TIMESTAMP DEFAULT CURRENT_TIMESTAMP
**Индексы:**
- PRIMARY KEY на `id`
- Индекс на `user_id`
- Индекс на `tag_id`
- UNIQUE CONSTRAINT на `(user_id, tag_id)`
## Анализ проблем в коде
Теперь, имея полную схему базы данных, давайте проверим код на соответствие:

View File

@@ -522,10 +522,11 @@ async function handleAiReply() {
.chat-container { .chat-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100%;
max-height: 100vh; max-height: 100%;
min-height: 0; min-height: 0;
position: relative; position: relative;
overflow: hidden;
} }
.chat-messages { .chat-messages {
@@ -533,6 +534,7 @@ async function handleAiReply() {
overflow-y: auto; overflow-y: auto;
position: relative; position: relative;
padding-bottom: 8px; padding-bottom: 8px;
min-height: 0;
} }
.chat-input { .chat-input {
@@ -544,49 +546,13 @@ async function handleAiReply() {
right: 0; right: 0;
border-radius: 12px 12px 0 0; border-radius: 12px 12px 0 0;
box-shadow: 0 -2px 8px rgba(0,0,0,0.04); box-shadow: 0 -2px 8px rgba(0,0,0,0.04);
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
min-height: 500px;
width: 100%;
position: relative;
background: transparent;
height: 100%;
}
.chat-messages {
display: flex;
flex-direction: column;
overflow-y: auto;
padding: var(--spacing-lg);
background: transparent;
border-radius: 0;
border: none;
flex: 1;
min-height: 0;
}
.chat-input {
display: flex;
flex-direction: column;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-white);
border-radius: 0;
border: none;
border-top: 1px solid #e9ecef;
flex-shrink: 0; flex-shrink: 0;
transition: all var(--transition-normal); min-height: 80px;
z-index: 10;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.05);
position: sticky;
bottom: 0;
} }
.chat-input textarea { .chat-input textarea {
width: 100%; width: 100%;
border: none; border: none;

View File

@@ -82,18 +82,19 @@
<thead> <thead>
<tr> <tr>
<th v-if="canViewContacts"><input type="checkbox" v-model="selectAll" @change="toggleSelectAll" /></th> <th v-if="canViewContacts"><input type="checkbox" v-model="selectAll" @change="toggleSelectAll" /></th>
<th>ID</th>
<th>Тип</th> <th>Тип</th>
<th>Имя</th> <th>Имя</th>
<th>Email</th> <th>Email</th>
<th>Telegram</th> <th>Telegram</th>
<th>Кошелек</th> <th>Кошелек</th>
<th>Дата создания</th> <th>Дата создания</th>
<th>Действие</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="contact in filteredContacts" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }"> <tr v-for="contact in filteredContacts" :key="contact.id" :class="{ 'new-contact-row': newIds.includes(contact.id) }" @click="goToContactDetails(contact.id)" style="cursor: pointer;">
<td v-if="canViewContacts"><input type="checkbox" v-model="selectedIds" :value="contact.id" /></td> <td v-if="canViewContacts" @click.stop><input type="checkbox" v-model="selectedIds" :value="contact.id" /></td>
<td>{{ contact.id }}</td>
<td> <td>
<span <span
v-if="getRoleDisplayName(contact.role)" v-if="getRoleDisplayName(contact.role)"
@@ -104,14 +105,10 @@
<span v-else class="user-badge">Неизвестно</span> <span v-else class="user-badge">Неизвестно</span>
</td> </td>
<td>{{ contact.name || '-' }}</td> <td>{{ contact.name || '-' }}</td>
<td>{{ contact.email || '-' }}</td> <td>{{ maskPersonalData(contact.email) }}</td>
<td>{{ contact.telegram || '-' }}</td> <td>{{ maskPersonalData(contact.telegram) }}</td>
<td>{{ contact.wallet || '-' }}</td> <td>{{ maskPersonalData(contact.wallet) }}</td>
<td>{{ contact.created_at ? new Date(contact.created_at).toLocaleString() : '-' }}</td> <td>{{ contact.created_at ? new Date(contact.created_at).toLocaleString() : '-' }}</td>
<td>
<span v-if="newMsgUserIds.includes(String(contact.id))" class="new-msg-icon" title="Новое сообщение"></span>
<button class="details-btn" @click="showDetails(contact)">Подробнее</button>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -132,6 +129,7 @@ import { useTagsWebSocket } from '../composables/useTagsWebSocket';
import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSocket'; import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSocket';
import { usePermissions } from '@/composables/usePermissions'; import { usePermissions } from '@/composables/usePermissions';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { PERMISSIONS } from '/app/shared/permissions.js';
import api from '../api/axios'; import api from '../api/axios';
import { sendMessage, getPrivateUnreadCount } from '../services/messagesService'; import { sendMessage, getPrivateUnreadCount } from '../services/messagesService';
import { useRoles } from '@/composables/useRoles'; import { useRoles } from '@/composables/useRoles';
@@ -147,7 +145,7 @@ const contactsArray = computed(() => props.contacts || []);
const newIds = computed(() => props.newContacts.map(c => c.id)); const newIds = computed(() => props.newContacts.map(c => c.id));
const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id))); const newMsgUserIds = computed(() => props.newMessages.map(m => String(m.user_id)));
const router = useRouter(); const router = useRouter();
const { canViewContacts, canSendToUsers, canDeleteData, canDeleteMessages, canManageSettings, canChatWithAdmins, canEditData } = usePermissions(); const { canViewContacts, canSendToUsers, canDeleteData, canDeleteMessages, canManageSettings, canChatWithAdmins, canEditData, hasPermission } = usePermissions();
const { userAccessLevel, userId, isAuthenticated } = useAuthContext(); const { userAccessLevel, userId, isAuthenticated } = useAuthContext();
const { roles, getRoleDisplayName, getRoleClass, fetchRoles, clearRoles } = useRoles(); const { roles, getRoleDisplayName, getRoleClass, fetchRoles, clearRoles } = useRoles();
@@ -175,6 +173,19 @@ async function loadPrivateUnreadCount() {
} }
} }
// Функция маскировки персональных данных для читателей
function maskPersonalData(data) {
if (!data || data === '-') return '-';
// Если пользователь имеет права редактора, показываем полные данные
if (hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS)) {
return data;
}
// Для читателей маскируем данные полностью звездочками
return '***';
}
// Новый фильтр тегов через мультисвязи // Новый фильтр тегов через мультисвязи
const availableTags = ref([]); const availableTags = ref([]);
const selectedTagIds = ref([]); const selectedTagIds = ref([]);
@@ -404,14 +415,14 @@ function formatDate(date) {
if (!date) return '-'; if (!date) return '-';
return new Date(date).toLocaleString(); return new Date(date).toLocaleString();
} }
async function showDetails(contact) { async function goToContactDetails(contactId) {
if (props.markContactAsRead) { if (props.markContactAsRead) {
await props.markContactAsRead(contact.id); await props.markContactAsRead(contactId);
} }
if (props.markMessagesAsReadForUser) { if (props.markMessagesAsReadForUser) {
props.markMessagesAsReadForUser(contact.id); props.markMessagesAsReadForUser(contactId);
} }
router.push({ name: 'contact-details', params: { id: contact.id } }); router.push({ name: 'contact-details', params: { id: contactId } });
} }
function onImported() { function onImported() {
@@ -430,7 +441,7 @@ async function openChatForSelected() {
if (!contact) return; if (!contact) return;
// Открываем чат с этим контактом (user_chat) // Открываем чат с этим контактом (user_chat)
await showDetails(contact); await goToContactDetails(contact.id);
} }
// Новая функция для отправки публичного сообщения // Новая функция для отправки публичного сообщения
@@ -448,7 +459,7 @@ function sendPublicMessage() {
} }
// Открываем страницу детали контакта с чатом для публичных сообщений // Открываем страницу детали контакта с чатом для публичных сообщений
showDetails(contact); goToContactDetails(contactId);
} }
// Функция для открытия приватного чата // Функция для открытия приватного чата

View File

@@ -40,6 +40,11 @@
<!-- Текстовый контент, если есть --> <!-- Текстовый контент, если есть -->
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="message.content" class="message-content" v-html="formattedContent" /> <div v-if="message.content" class="message-content" v-html="formattedContent" />
<!-- Ссылка "Ответить" для публичных сообщений от других пользователей -->
<div v-if="shouldShowReplyLink" class="message-reply-link">
<a :href="replyLink" class="reply-link">Ответить</a>
</div>
<!-- Кнопки для системного сообщения --> <!-- Кнопки для системного сообщения -->
<div v-if="message.sender_type === 'system' && (message.telegramBotUrl || message.supportEmail)" class="system-actions"> <div v-if="message.sender_type === 'system' && (message.telegramBotUrl || message.supportEmail)" class="system-actions">
@@ -127,6 +132,11 @@ const isCurrentUserMessage = computed(() => {
return props.message.sender_id === props.message.user_id; return props.message.sender_id === props.message.user_id;
} }
// Для публичных сообщений сравниваем sender_id с currentUserId
if (props.message.message_type === 'public' && props.currentUserId) {
return props.message.sender_id == props.currentUserId;
}
// Для обычных сообщений используем стандартную логику // Для обычных сообщений используем стандартную логику
return props.message.sender_type === 'user' || props.message.role === 'user'; return props.message.sender_type === 'user' || props.message.role === 'user';
}); });
@@ -145,6 +155,22 @@ const formatWalletAddress = (address) => {
return address; return address;
}; };
// --- Логика ссылки "Ответить" для публичных сообщений ---
const shouldShowReplyLink = computed(() => {
// Показываем ссылку только для публичных сообщений от других пользователей
return props.message.message_type === 'public' &&
!isCurrentUserMessage.value &&
props.message.sender_id &&
props.currentUserId &&
props.message.sender_id !== props.currentUserId;
});
const replyLink = computed(() => {
if (!shouldShowReplyLink.value) return '';
// Ссылка ведет на страницу контакта отправителя
return `/contacts/${props.message.sender_id}`;
});
// --- Работа с вложениями --- // --- Работа с вложениями ---
const attachment = computed(() => { const attachment = computed(() => {
// Ожидаем массив attachments, даже если там только один элемент // Ожидаем массив attachments, даже если там только один элемент
@@ -554,6 +580,30 @@ function copyEmail(email) {
} }
} }
/* Стили для ссылки "Ответить" */
.message-reply-link {
margin-top: var(--spacing-xs);
text-align: right;
}
.reply-link {
color: var(--color-primary, #007bff);
text-decoration: none;
font-size: var(--font-size-sm);
font-weight: 500;
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-sm);
background-color: rgba(0, 123, 255, 0.1);
transition: all 0.2s ease;
display: inline-block;
}
.reply-link:hover {
background-color: rgba(0, 123, 255, 0.2);
color: var(--color-primary-dark, #0056b3);
text-decoration: none;
}
/* Адаптивность для мобильных устройств */ /* Адаптивность для мобильных устройств */
@media (max-width: 768px) { @media (max-width: 768px) {
.private-current-user, .private-current-user,

View File

@@ -105,10 +105,16 @@ export function useChat(auth) {
let totalMessages = -1; let totalMessages = -1;
if (initial || messageLoading.value.offset === 0) { if (initial || messageLoading.value.offset === 0) {
try { try {
const countResponse = await api.get('/messages/public', { params: { count_only: true } }); // Получаем количество личных сообщений с ИИ
if (!countResponse.data.success) throw new Error('Не удалось получить количество сообщений'); const personalCountResponse = await api.get('/chat/history', { params: { count_only: true } });
totalMessages = countResponse.data.total || countResponse.data.count || 0; const personalCount = personalCountResponse.data.success ? (personalCountResponse.data.total || 0) : 0;
// console.log(`[useChat] Всего сообщений в истории: ${totalMessages}`);
// Получаем количество публичных сообщений
const publicCountResponse = await api.get('/messages/public', { params: { count_only: true } });
const publicCount = publicCountResponse.data.success ? (publicCountResponse.data.total || 0) : 0;
totalMessages = personalCount + publicCount;
// console.log(`[useChat] Всего сообщений в истории: ${totalMessages} (личные: ${personalCount}, публичные: ${publicCount})`);
} catch(countError) { } catch(countError) {
// console.error('[useChat] Ошибка получения количества сообщений:', countError); // console.error('[useChat] Ошибка получения количества сообщений:', countError);
// Не прерываем выполнение, попробуем загрузить без total // Не прерываем выполнение, попробуем загрузить без total
@@ -122,13 +128,41 @@ export function useChat(auth) {
// console.log(`[useChat] Рассчитано начальное смещение: ${effectiveOffset}`); // console.log(`[useChat] Рассчитано начальное смещение: ${effectiveOffset}`);
} }
// Используем новый API для публичных сообщений с пагинацией // Загружаем личные сообщения с ИИ
const response = await api.get('/messages/public', { const personalResponse = await api.get('/chat/history', {
params: { params: {
offset: effectiveOffset, offset: effectiveOffset,
limit: messageLoading.value.limit limit: messageLoading.value.limit
} }
}); });
// Загружаем публичные сообщения от других пользователей
const publicResponse = await api.get('/messages/public', {
params: {
offset: 0,
limit: 50
}
});
// Объединяем сообщения
let allMessages = [];
if (personalResponse.data.success && personalResponse.data.messages) {
allMessages = [...allMessages, ...personalResponse.data.messages];
}
if (publicResponse.data.success && publicResponse.data.messages) {
allMessages = [...allMessages, ...publicResponse.data.messages];
}
// Сортируем по времени создания
allMessages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
const response = {
data: {
success: true,
messages: allMessages,
total: allMessages.length
}
};
if (response.data.success && response.data.messages) { if (response.data.success && response.data.messages) {
const loadedMessages = response.data.messages; const loadedMessages = response.data.messages;

View File

@@ -166,4 +166,13 @@ export async function markPrivateMessagesAsRead(conversationId) {
conversationId conversationId
}); });
return data; return data;
}
// Функция для загрузки личных сообщений с ИИ
export async function getPersonalChatHistory(options = {}) {
const { limit = 50, offset = 0 } = options;
const { data } = await api.get('/chat/history', {
params: { limit, offset }
});
return data;
} }

View File

@@ -33,6 +33,7 @@
:messages="messages" :messages="messages"
:is-loading="isLoading || isConnectingWallet" :is-loading="isLoading || isConnectingWallet"
:has-more-messages="messageLoading.hasMoreMessages" :has-more-messages="messageLoading.hasMoreMessages"
:currentUserId="auth.userId"
v-model:newMessage="newMessage" v-model:newMessage="newMessage"
v-model:attachments="attachments" v-model:attachments="attachments"
@send-message="handleSendMessage" @send-message="handleSendMessage"
@@ -44,6 +45,7 @@
:messages="messages" :messages="messages"
:is-loading="isLoading || isConnectingWallet" :is-loading="isLoading || isConnectingWallet"
:has-more-messages="messageLoading.hasMoreMessages" :has-more-messages="messageLoading.hasMoreMessages"
:currentUserId="auth.userId"
v-model:newMessage="newMessage" v-model:newMessage="newMessage"
v-model:attachments="attachments" v-model:attachments="attachments"
@send-message="handleSendMessage" @send-message="handleSendMessage"
@@ -161,6 +163,8 @@
background-color: var(--color-white); background-color: var(--color-white);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
height: calc(100vh - 40px); height: calc(100vh - 40px);
display: flex;
flex-direction: column;
overflow: hidden; overflow: hidden;
} }
@@ -171,6 +175,7 @@
margin-bottom: 2rem; margin-bottom: 2rem;
padding-bottom: 1rem; padding-bottom: 1rem;
border-bottom: 2px solid #e9ecef; border-bottom: 2px solid #e9ecef;
flex-shrink: 0;
} }
.header-content h1 { .header-content h1 {
@@ -190,6 +195,7 @@
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
} }
/* Адаптивность */ /* Адаптивность */

View File

@@ -21,7 +21,9 @@
<h2>Детали контакта</h2> <h2>Детали контакта</h2>
<button class="close-btn" @click="goBack">×</button> <button class="close-btn" @click="goBack">×</button>
</div> </div>
<div class="contact-info-block"> <div class="contact-info-section">
<div class="contact-info-block">
<div><strong>ID пользователя:</strong> {{ contact.id }}</div>
<div> <div>
<strong>Имя:</strong> <strong>Имя:</strong>
<template v-if="canEditContacts"> <template v-if="canEditContacts">
@@ -32,9 +34,9 @@
{{ contact.name }} {{ contact.name }}
</template> </template>
</div> </div>
<div><strong>Email:</strong> {{ contact.email || '-' }}</div> <div><strong>Email:</strong> {{ maskPersonalData(contact.email) }}</div>
<div><strong>Telegram:</strong> {{ contact.telegram || '-' }}</div> <div><strong>Telegram:</strong> {{ maskPersonalData(contact.telegram) }}</div>
<div><strong>Кошелек:</strong> {{ contact.wallet || '-' }}</div> <div><strong>Кошелек:</strong> {{ maskPersonalData(contact.wallet) }}</div>
<div> <div>
<strong>Язык:</strong> <strong>Язык:</strong>
<div class="multi-select"> <div class="multi-select">
@@ -101,6 +103,7 @@
<button class="delete-history-btn" @click="deleteMessagesHistory">Удалить историю сообщений</button> <button class="delete-history-btn" @click="deleteMessagesHistory">Удалить историю сообщений</button>
<button class="delete-btn" @click="deleteContact">Удалить контакт</button> <button class="delete-btn" @click="deleteContact">Удалить контакт</button>
</div> </div>
</div>
</div> </div>
<div class="messages-block"> <div class="messages-block">
<h3>Чат с пользователем</h3> <h3>Чат с пользователем</h3>
@@ -109,9 +112,10 @@
:isLoading="isLoadingMessages" :isLoading="isLoadingMessages"
:attachments="chatAttachments" :attachments="chatAttachments"
:newMessage="chatNewMessage" :newMessage="chatNewMessage"
:canSend="canSendToUsers" :canSend="canSendToUsers && !!address"
:canGenerateAI="canGenerateAI" :canGenerateAI="canGenerateAI"
:canSelectMessages="canGenerateAI" :canSelectMessages="canGenerateAI"
: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"
@@ -160,11 +164,13 @@ import Message from '../../components/Message.vue';
import ChatInterface from '../../components/ChatInterface.vue'; import ChatInterface from '../../components/ChatInterface.vue';
import contactsService from '../../services/contactsService.js'; import contactsService from '../../services/contactsService.js';
import messagesService from '../../services/messagesService.js'; import messagesService from '../../services/messagesService.js';
import { getPublicMessages, getConversationByUserId } from '../../services/messagesService.js'; import { getPublicMessages, getConversationByUserId, sendMessage, getPersonalChatHistory } from '../../services/messagesService.js';
import { useAuthContext } from '@/composables/useAuth'; import { useAuthContext } from '@/composables/useAuth';
import { usePermissions } from '@/composables/usePermissions'; import { usePermissions } from '@/composables/usePermissions';
import { PERMISSIONS } from '/app/shared/permissions.js';
import { useContactsAndMessagesWebSocket } from '@/composables/useContactsWebSocket'; import { useContactsAndMessagesWebSocket } from '@/composables/useContactsWebSocket';
const { canEditContacts, canDeleteData, canManageTags, canBlockUsers, canSendToUsers, canGenerateAI, canViewContacts } = usePermissions(); const { canEditContacts, canDeleteData, canManageTags, canBlockUsers, canSendToUsers, canGenerateAI, canViewContacts, hasPermission } = usePermissions();
const { address, userId: currentUserId } = useAuthContext();
const { markContactAsRead } = useContactsAndMessagesWebSocket(); const { markContactAsRead } = useContactsAndMessagesWebSocket();
// Подписываемся на централизованные события очистки и обновления данных // Подписываемся на централизованные события очистки и обновления данных
@@ -214,6 +220,19 @@ const tagsTableId = ref(null);
const { onTagsUpdate } = useTagsWebSocket(); const { onTagsUpdate } = useTagsWebSocket();
let unsubscribeFromTags = null; let unsubscribeFromTags = null;
// Функция маскировки персональных данных для читателей
function maskPersonalData(data) {
if (!data || data === '-') return '-';
// Если пользователь имеет права редактора, показываем полные данные
if (hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS)) {
return data;
}
// Для читателей маскируем данные полностью звездочками
return '***';
}
async function ensureTagsTable() { async function ensureTagsTable() {
// Получаем все пользовательские таблицы // Получаем все пользовательские таблицы
const tables = await tablesService.getTables(); const tables = await tablesService.getTables();
@@ -402,16 +421,42 @@ async function loadMessages() {
console.log('[ContactDetailsView] 📥 loadMessages START for:', contact.value.id); console.log('[ContactDetailsView] 📥 loadMessages START for:', contact.value.id);
isLoadingMessages.value = true; isLoadingMessages.value = true;
try { try {
// Загружаем только публичные сообщения этого пользователя с пагинацией // Проверяем, является ли контакт собственным ID пользователя
const response = await getPublicMessages(contact.value.id, { limit: 50, offset: 0 }); const isOwnContact = currentUserId.value && contact.value.id == currentUserId.value;
console.log('[ContactDetailsView] 📩 Loaded messages:', response.messages?.length || 0, 'for', contact.value.id);
if (response.success && response.messages) { let allMessages = [];
messages.value = response.messages;
if (isOwnContact) {
// Для собственного ID загружаем И личные сообщения с ИИ, И публичные сообщения от других пользователей
console.log('[ContactDetailsView] 🔍 Loading personal chat with AI + public messages for own ID:', contact.value.id);
// Загружаем личные сообщения с ИИ
const personalResponse = await getPersonalChatHistory({ limit: 50, offset: 0 });
if (personalResponse.success && personalResponse.messages) {
allMessages = [...allMessages, ...personalResponse.messages];
}
// Загружаем публичные сообщения от других пользователей (входящие)
const publicResponse = await getPublicMessages(contact.value.id, { limit: 50, offset: 0 });
if (publicResponse.success && publicResponse.messages) {
allMessages = [...allMessages, ...publicResponse.messages];
}
// Сортируем по времени создания
allMessages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
} else { } else {
messages.value = []; // Для других пользователей загружаем публичные сообщения между текущим пользователем и выбранным контактом
console.log('[ContactDetailsView] 🔍 Loading public messages between current user and contact:', contact.value.id);
const response = await getPublicMessages(contact.value.id, { limit: 50, offset: 0 });
if (response.success && response.messages) {
allMessages = response.messages;
}
} }
console.log('[ContactDetailsView] 📩 Loaded messages:', allMessages.length, 'for', contact.value.id);
messages.value = allMessages;
if (messages.value.length > 0) { if (messages.value.length > 0) {
lastMessageDate.value = messages.value[messages.value.length - 1].created_at; lastMessageDate.value = messages.value[messages.value.length - 1].created_at;
} else { } else {
@@ -487,27 +532,23 @@ async function handleSendMessage({ message, attachments }) {
return; return;
} }
try { try {
const result = await messagesService.broadcastMessage({ const result = await sendMessage({
userId: contact.value.id, recipientId: contact.value.id,
message, content: message,
attachments messageType: 'public'
}); });
// Формируем текст результата для отображения админу
let resultText = ''; if (result && result.success) {
if (result && Array.isArray(result.results)) { // Очищаем поле ввода после успешной отправки
resultText = 'Результат рассылки по каналам:'; chatNewMessage.value = '';
for (const r of result.results) { // Обновляем список сообщений
resultText += `\n${r.channel}: ${(r.status === 'sent' || r.status === 'saved') ? 'Успех' : 'Ошибка'}${r.error ? ' (' + r.error + ')' : ''}`; await loadMessages();
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert('Сообщение отправлено успешно', 'Успех', { type: 'success' });
} }
} else { } else {
resultText = 'Не удалось получить подробный ответ от сервера.'; throw new Error(result?.message || 'Неизвестная ошибка');
} }
if (typeof ElMessageBox === 'function') {
ElMessageBox.alert(resultText, 'Результат рассылки', { type: 'info' });
} else {
console.log('Результат рассылки:', resultText);
}
await loadMessages();
} catch (e) { } catch (e) {
if (typeof ElMessageBox === 'function') { if (typeof ElMessageBox === 'function') {
ElMessageBox.alert('Ошибка отправки: ' + (e?.response?.data?.error || e?.message || e), 'Ошибка', { type: 'error' }); ElMessageBox.alert('Ошибка отправки: ' + (e?.response?.data?.error || e?.message || e), 'Ошибка', { type: 'error' });
@@ -701,23 +742,28 @@ watch(userId, async () => {
<style scoped> <style scoped>
.contact-details-page { .contact-details-page {
padding: 32px 0; padding: 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
} }
.contact-details-content { .contact-details-content {
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 4px 32px rgba(0,0,0,0.12); box-shadow: 0 4px 32px rgba(0,0,0,0.12);
padding: 32px 24px 24px 24px; padding: 24px;
width: 100%; width: 100%;
margin-top: 40px; flex: 1;
position: relative; display: flex;
overflow-x: auto; flex-direction: column;
gap: 20px;
} }
.contact-details-header { .contact-details-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 18px; flex-shrink: 0;
} }
.close-btn { .close-btn {
background: none; background: none;
@@ -730,8 +776,14 @@ watch(userId, async () => {
.close-btn:hover { .close-btn:hover {
color: #333; color: #333;
} }
.contact-info-section {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.contact-info-block { .contact-info-block {
margin-bottom: 18px;
font-size: 1.08rem; font-size: 1.08rem;
line-height: 1.7; line-height: 1.7;
} }
@@ -752,6 +804,7 @@ watch(userId, async () => {
display: flex; display: flex;
gap: 12px; gap: 12px;
margin-top: 18px; margin-top: 18px;
flex-shrink: 0;
} }
.delete-history-btn { .delete-history-btn {
@@ -858,6 +911,11 @@ watch(userId, async () => {
border-radius: 10px; border-radius: 10px;
padding: 18px; padding: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
flex: 1;
display: flex;
flex-direction: column;
min-height: 500px;
max-height: 70vh;
} }
.messages-list { .messages-list {
max-height: 350px; max-height: 350px;

View File

@@ -186,6 +186,54 @@ function getRoleDescription(role) {
return descriptions[role] || 'Неизвестная роль'; return descriptions[role] || 'Неизвестная роль';
} }
/**
* Проверить, может ли отправитель отправлять сообщения получателю
* @param {string} senderRole - Роль отправителя
* @param {string} recipientRole - Роль получателя
* @param {number} senderId - ID отправителя
* @param {number} recipientId - ID получателя
* @returns {Object} { canSend: boolean, errorMessage?: string }
*/
function canSendMessage(senderRole, recipientRole, senderId, recipientId) {
// Проверяем базовое право на отправку сообщений
if (!hasPermission(senderRole, PERMISSIONS.SEND_TO_USERS)) {
return {
canSend: false,
errorMessage: 'У вас нет права на отправку сообщений'
};
}
// Собственный чат - всегда разрешен (для ИИ ассистента)
if (senderId === recipientId) {
return { canSend: true };
}
// USER и READONLY могут писать только EDITOR
if ((senderRole === 'user' || senderRole === 'readonly') && recipientRole === 'editor') {
return { canSend: true };
}
// EDITOR может писать всем (USER, READONLY, EDITOR)
if (senderRole === 'editor') {
return { canSend: true };
}
// USER и READONLY НЕ могут писать друг другу
if ((senderRole === 'user' || senderRole === 'readonly') &&
(recipientRole === 'user' || recipientRole === 'readonly')) {
return {
canSend: false,
errorMessage: 'Пользователи и читатели не могут отправлять сообщения друг другу'
};
}
// Остальные случаи запрещены
return {
canSend: false,
errorMessage: `Роль ${senderRole} не может отправлять сообщения роли ${recipientRole}`
};
}
// Экспорты для CommonJS (Node.js) // Экспорты для CommonJS (Node.js)
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
module.exports = { module.exports = {
@@ -196,7 +244,8 @@ if (typeof module !== 'undefined' && module.exports) {
getPermissionsForRole, getPermissionsForRole,
hasAnyPermission, hasAnyPermission,
hasAllPermissions, hasAllPermissions,
getRoleDescription getRoleDescription,
canSendMessage
}; };
} }
@@ -209,7 +258,8 @@ export {
getPermissionsForRole, getPermissionsForRole,
hasAnyPermission, hasAnyPermission,
hasAllPermissions, hasAllPermissions,
getRoleDescription getRoleDescription,
canSendMessage
}; };
// CommonJS для Backend // CommonJS для Backend
@@ -222,7 +272,8 @@ if (typeof module !== 'undefined' && module.exports) {
getPermissionsForRole, getPermissionsForRole,
hasAnyPermission, hasAnyPermission,
hasAllPermissions, hasAllPermissions,
getRoleDescription getRoleDescription,
canSendMessage
}; };
} }