From f728c5f5da4387f21f92a323d828ea8c30d07144 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 19 Jun 2025 20:19:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=88=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app.js | 12 ++ backend/db.js | 10 +- .../db/migrations/035_admin_read_messages.sql | 6 + .../db/migrations/036_admin_read_contacts.sql | 6 + backend/routes/messages.js | 68 +++++++ backend/routes/users.js | 53 +++++ backend/services/auth-service.js | 5 + backend/services/emailAuth.js | 7 + backend/services/emailBot.js | 3 + backend/services/identity-service.js | 1 + backend/services/telegramBot.js | 4 + backend/wsHub.js | 10 +- frontend/src/components/BaseLayout.vue | 4 +- frontend/src/components/ContactTable.vue | 94 ++++++++- .../src/composables/useContactsWebSocket.js | 149 +++++++++++--- frontend/src/services/contactsService.js | 2 +- frontend/src/services/messagesService.js | 2 +- frontend/src/views/ContactsView.vue | 43 +--- frontend/src/views/CrmView.vue | 1 - frontend/src/views/SettingsView.vue | 1 - .../src/views/contacts/ContactDetailsView.vue | 190 +++++++++--------- frontend/src/views/tables/TableView.vue | 16 +- frontend/src/views/tables/TablesListView.vue | 6 +- .../src/views/tables/TagsTableViewPage.vue | 2 +- 24 files changed, 512 insertions(+), 183 deletions(-) create mode 100644 backend/db/migrations/035_admin_read_messages.sql create mode 100644 backend/db/migrations/036_admin_read_contacts.sql diff --git a/backend/app.js b/backend/app.js index 88948f2..64975db 100644 --- a/backend/app.js +++ b/backend/app.js @@ -141,6 +141,18 @@ app.use(async (req, res, next) => { next(); }); +// Middleware для подстановки req.user из сессии +app.use((req, res, next) => { + if (req.session && req.session.userId) { + req.user = { + id: req.session.userId, + isAdmin: req.session.isAdmin, + address: req.session.address, + }; + } + next(); +}); + // Настройка парсеров app.use(express.json()); app.use(express.urlencoded({ extended: true })); diff --git a/backend/db.js b/backend/db.js index fedf9e8..ae44c93 100644 --- a/backend/db.js +++ b/backend/db.js @@ -34,8 +34,12 @@ function getPool() { return pool; } +function query(text, params) { + return pool.query(text, params); +} + function getQuery() { - return pool.query.bind(pool); + return (...args) => pool.query(...args); } let poolChangeCallback = null; @@ -77,8 +81,6 @@ if (process.env.NODE_ENV !== 'migration') { reinitPoolFromDbSettings(); } -const query = (text, params) => pool.query(text, params); - // Функция для сохранения гостевого сообщения в базе данных async function saveGuestMessageToDatabase(message, language, guestId) { try { @@ -97,4 +99,4 @@ async function saveGuestMessageToDatabase(message, language, guestId) { } // Экспортируем функции для работы с базой данных -module.exports = { query: pool.query.bind(pool), getQuery, pool, getPool, setPoolChangeCallback }; +module.exports = { query, getQuery, pool, getPool, setPoolChangeCallback }; diff --git a/backend/db/migrations/035_admin_read_messages.sql b/backend/db/migrations/035_admin_read_messages.sql new file mode 100644 index 0000000..91b8bcd --- /dev/null +++ b/backend/db/migrations/035_admin_read_messages.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS admin_read_messages ( + admin_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + last_read_at TIMESTAMP NOT NULL, + PRIMARY KEY (admin_id, user_id) +); \ No newline at end of file diff --git a/backend/db/migrations/036_admin_read_contacts.sql b/backend/db/migrations/036_admin_read_contacts.sql new file mode 100644 index 0000000..c06db63 --- /dev/null +++ b/backend/db/migrations/036_admin_read_contacts.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS admin_read_contacts ( + admin_id INTEGER NOT NULL, + contact_id INTEGER NOT NULL, + read_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (admin_id, contact_id) +); \ No newline at end of file diff --git a/backend/routes/messages.js b/backend/routes/messages.js index 3fa5094..9546982 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -1,6 +1,7 @@ const express = require('express'); const router = express.Router(); const db = require('../db'); +const { broadcastMessagesUpdate } = require('../wsHub'); // GET /api/messages?userId=123 router.get('/', async (req, res) => { @@ -28,4 +29,71 @@ router.get('/', async (req, res) => { } }); +// POST /api/messages +router.post('/', async (req, res) => { + const { user_id, sender_type, content, channel, role, direction, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata } = req.body; + try { + const result = await db.getQuery()( + `INSERT INTO messages (user_id, sender_type, content, channel, role, direction, created_at, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata) + VALUES ($1,$2,$3,$4,$5,$6,NOW(),$7,$8,$9,$10,$11) RETURNING *`, + [user_id, sender_type, content, channel, role, direction, attachment_filename, attachment_mimetype, attachment_size, attachment_data, metadata] + ); + 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 +router.post('/mark-read', async (req, res) => { + try { + console.log('[DEBUG] /mark-read req.user:', req.user); + console.log('[DEBUG] /mark-read req.body:', req.body); + const adminId = req.user && req.user.id; + const { userId, lastReadAt } = req.body; + if (!adminId) { + console.error('[ERROR] /mark-read: adminId (req.user.id) is missing'); + return res.status(401).json({ error: 'Unauthorized: adminId missing' }); + } + if (!userId || !lastReadAt) { + console.error('[ERROR] /mark-read: userId or lastReadAt missing'); + return res.status(400).json({ error: 'userId and lastReadAt required' }); + } + await db.query(` + INSERT INTO admin_read_messages (admin_id, user_id, last_read_at) + VALUES ($1, $2, $3) + ON CONFLICT (admin_id, user_id) DO UPDATE SET last_read_at = EXCLUDED.last_read_at + `, [adminId, userId, lastReadAt]); + res.json({ success: true }); + } catch (e) { + console.error('[ERROR] /mark-read:', e); + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/messages/read-status +router.get('/read-status', async (req, res) => { + try { + console.log('[DEBUG] /read-status req.user:', req.user); + console.log('[DEBUG] /read-status req.session:', req.session); + console.log('[DEBUG] /read-status req.session.userId:', req.session && req.session.userId); + const adminId = req.user && req.user.id; + if (!adminId) { + console.error('[ERROR] /read-status: adminId (req.user.id) is missing'); + return res.status(401).json({ error: 'Unauthorized: adminId missing' }); + } + const result = await db.query('SELECT user_id, last_read_at FROM admin_read_messages WHERE admin_id = $1', [adminId]); + console.log('[DEBUG] /read-status SQL result:', result.rows); + const map = {}; + for (const row of result.rows) { + map[row.user_id] = row.last_read_at; + } + res.json(map); + } catch (e) { + console.error('[ERROR] /read-status:', e); + res.status(500).json({ error: e.message }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/routes/users.js b/backend/routes/users.js index ea6dc14..8d222d2 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -99,6 +99,43 @@ router.get('/', async (req, res) => { }); */ +// Получить просмотренные контакты +router.get('/read-contacts-status', async (req, res) => { + try { + const adminId = req.user && req.user.id; + if (!adminId) { + return res.status(401).json({ error: 'Unauthorized: adminId missing' }); + } + const result = await db.query( + 'SELECT contact_id FROM admin_read_contacts WHERE admin_id = $1', + [adminId] + ); + res.json(result.rows.map(r => r.contact_id)); + } catch (e) { + console.error('[ERROR] /read-contacts-status:', e); + res.status(500).json({ error: e.message }); + } +}); + +// Пометить контакт как просмотренный +router.post('/mark-contact-read', async (req, res) => { + try { + const adminId = req.user && req.user.id; + const { contactId } = req.body; + if (!adminId || !contactId) { + return res.status(400).json({ error: 'adminId and contactId required' }); + } + await db.query( + 'INSERT INTO admin_read_contacts (admin_id, contact_id, read_at) VALUES ($1, $2, NOW()) ON CONFLICT (admin_id, contact_id) DO UPDATE SET read_at = NOW()', + [adminId, contactId] + ); + res.json({ success: true }); + } catch (e) { + console.error('[ERROR] /mark-contact-read:', e); + res.status(500).json({ error: e.message }); + } +}); + // PATCH /api/users/:id — обновить имя и язык router.patch('/:id', async (req, res) => { const userId = req.params.id; @@ -180,4 +217,20 @@ router.get('/:id', async (req, res, next) => { } }); +// POST /api/users +router.post('/', async (req, res) => { + const { first_name, last_name, preferred_language } = req.body; + try { + const result = await db.getQuery()( + `INSERT INTO users (first_name, last_name, preferred_language, created_at) + VALUES ($1, $2, $3, NOW()) RETURNING *`, + [first_name, last_name, JSON.stringify(preferred_language || [])] + ); + broadcastContactsUpdate(); + res.json({ success: true, user: result.rows[0] }); + } catch (e) { + res.status(500).json({ error: 'DB error', details: e.message }); + } +}); + module.exports = router; diff --git a/backend/services/auth-service.js b/backend/services/auth-service.js index bd0ada1..be48960 100644 --- a/backend/services/auth-service.js +++ b/backend/services/auth-service.js @@ -9,6 +9,7 @@ const authTokenService = require('./authTokenService'); const rpcProviderService = require('./rpcProviderService'); const { getLinkedWallet } = require('./wallet-service'); const { checkAdminRole } = require('./admin-role'); +const { broadcastContactsUpdate } = require('../wsHub'); const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)']; @@ -102,6 +103,8 @@ class AuthService { ); } + broadcastContactsUpdate(); + return { userId, isAdmin }; } catch (error) { logger.error('Error finding or creating user:', error); @@ -743,6 +746,8 @@ class AuthService { delete session.tempUserId; delete session.pendingEmail; + broadcastContactsUpdate(); + return { userId, email: normalizedEmail, diff --git a/backend/services/emailAuth.js b/backend/services/emailAuth.js index ed1cee1..2db3415 100644 --- a/backend/services/emailAuth.js +++ b/backend/services/emailAuth.js @@ -5,6 +5,7 @@ const EmailBotService = require('./emailBot.js'); const db = require('../db'); const authService = require('./auth-service'); const { checkAdminRole } = require('./admin-role'); +const { broadcastContactsUpdate } = require('../wsHub'); class EmailAuth { constructor() { @@ -65,6 +66,9 @@ class EmailAuth { `Generated verification code for Email auth for ${email} and sent to user's email` ); + // После каждого успешного создания пользователя: + broadcastContactsUpdate(); + return { success: true, verificationCode }; } catch (error) { logger.error('Error in email auth initialization:', error); @@ -201,6 +205,9 @@ class EmailAuth { delete session.tempUserId; } + // После каждого успешного создания пользователя: + broadcastContactsUpdate(); + return { verified: true, userId: finalUserId, diff --git a/backend/services/emailBot.js b/backend/services/emailBot.js index 1bcac1b..e1e38e8 100644 --- a/backend/services/emailBot.js +++ b/backend/services/emailBot.js @@ -8,6 +8,7 @@ const { inspect } = require('util'); const logger = require('../utils/logger'); const identityService = require('./identity-service'); const aiAssistant = require('./ai-assistant'); +const { broadcastContactsUpdate } = require('../wsHub'); class EmailBotService { constructor() { @@ -172,6 +173,8 @@ class EmailBotService { ); // 5. Отправить ответ на email await this.sendEmail(fromEmail, 'Re: ' + subject, aiResponse); + // После каждого успешного создания пользователя: + broadcastContactsUpdate(); } catch (processErr) { logger.error('Error processing incoming email:', processErr); } diff --git a/backend/services/identity-service.js b/backend/services/identity-service.js index b40682b..532ddd8 100644 --- a/backend/services/identity-service.js +++ b/backend/services/identity-service.js @@ -544,6 +544,7 @@ class IdentityService { await this.saveIdentity(userId, provider, providerId, true); user = { id: userId, role: 'user' }; isNew = true; + logger.info('[WS] broadcastContactsUpdate after new user created'); broadcastContactsUpdate(); } // Проверяем связь с кошельком diff --git a/backend/services/telegramBot.js b/backend/services/telegramBot.js index ae2c3eb..a2433aa 100644 --- a/backend/services/telegramBot.js +++ b/backend/services/telegramBot.js @@ -7,6 +7,7 @@ const crypto = require('crypto'); const identityService = require('./identity-service'); const aiAssistant = require('./ai-assistant'); const { checkAdminRole } = require('./admin-role'); +const { broadcastContactsUpdate } = require('../wsHub'); let botInstance = null; let telegramSettingsCache = null; @@ -252,6 +253,9 @@ async function getBot() { } catch (error) { logger.warn('Could not delete code message:', error); } + + // После каждого успешного создания пользователя: + broadcastContactsUpdate(); } catch (error) { logger.error('Error in Telegram auth:', error); await ctx.reply('Произошла ошибка при аутентификации. Попробуйте позже.'); diff --git a/backend/wsHub.js b/backend/wsHub.js index 4fe3623..f6daa6c 100644 --- a/backend/wsHub.js +++ b/backend/wsHub.js @@ -19,4 +19,12 @@ function broadcastContactsUpdate() { } } -module.exports = { initWSS, broadcastContactsUpdate }; \ No newline at end of file +function broadcastMessagesUpdate() { + for (const ws of wsClients) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'messages-updated' })); + } + } +} + +module.exports = { initWSS, broadcastContactsUpdate, broadcastMessagesUpdate }; \ No newline at end of file diff --git a/frontend/src/components/BaseLayout.vue b/frontend/src/components/BaseLayout.vue index 48eb41a..f350163 100644 --- a/frontend/src/components/BaseLayout.vue +++ b/frontend/src/components/BaseLayout.vue @@ -176,8 +176,8 @@ onMounted(() => { if (savedSidebarState !== null) { showWalletSidebar.value = savedSidebarState; } else { - showWalletSidebar.value = true; - setToStorage('showWalletSidebar', true); + showWalletSidebar.value = false; // по умолчанию закрыт + setToStorage('showWalletSidebar', false); } }); diff --git a/frontend/src/components/ContactTable.vue b/frontend/src/components/ContactTable.vue index 143dde1..fde9d05 100644 --- a/frontend/src/components/ContactTable.vue +++ b/frontend/src/components/ContactTable.vue @@ -4,6 +4,17 @@

Контакты

+
+ + + + + + + +
@@ -16,13 +27,14 @@ - + - + @@ -32,17 +44,58 @@ @@ -144,4 +197,35 @@ function showDetails(contact) { .details-btn:hover { background: #138496; } +.new-contact-row { + background: #e6ffe6 !important; + transition: background 0.3s; +} +.filters-panel { + display: flex; + gap: 10px; + margin-bottom: 18px; + align-items: center; +} +.filters-panel input { + padding: 6px 10px; + border: 1px solid #d0d7de; + border-radius: 6px; + font-size: 1em; + min-width: 110px; +} +.filters-panel input[type="checkbox"] { + margin-right: 4px; +} +.checkbox-label { + display: flex; + align-items: center; + font-size: 0.98em; + user-select: none; +} +.new-msg-icon { + color: #ff9800; + font-size: 1.2em; + margin-left: 4px; +} \ No newline at end of file diff --git a/frontend/src/composables/useContactsWebSocket.js b/frontend/src/composables/useContactsWebSocket.js index 5f6f4c2..75d62ad 100644 --- a/frontend/src/composables/useContactsWebSocket.js +++ b/frontend/src/composables/useContactsWebSocket.js @@ -1,55 +1,150 @@ import { ref, onMounted, onUnmounted } from 'vue'; import { getContacts } from '../services/contactsService'; import { getAllMessages } from '../services/messagesService'; +import axios from 'axios'; export function useContactsAndMessagesWebSocket() { const contacts = ref([]); const messages = ref([]); + const readContacts = ref([]); // id просмотренных контактов const newContacts = ref([]); const newMessages = ref([]); + const readUserIds = ref([]); + const lastReadMessageDate = ref({}); let ws = null; - let lastContactId = null; - let lastMessageId = null; + let lastMessageDate = null; + + // Загружаем прочитанные userId из localStorage при инициализации + try { + const stored = localStorage.getItem('readUserIds'); + if (stored) { + readUserIds.value = JSON.parse(stored); + } + } catch (e) { + readUserIds.value = []; + } + + // Загружаем lastReadMessageDate из localStorage при инициализации + try { + const stored = localStorage.getItem('lastReadMessageDate'); + if (stored) { + lastReadMessageDate.value = JSON.parse(stored); + } + } catch (e) { + lastReadMessageDate.value = {}; + } async function fetchContacts() { const all = await getContacts(); contacts.value = all; - if (lastContactId) { - newContacts.value = all.filter(c => c.id > lastContactId); - } else { - newContacts.value = []; + updateNewContacts(); + } + + async function fetchContactsReadStatus() { + try { + const { data } = await axios.get('/api/users/read-contacts-status'); + readContacts.value = data || []; + } catch (e) { + readContacts.value = []; + } + updateNewContacts(); + } + + function updateNewContacts() { + if (!contacts.value.length) { + newContacts.value = []; + return; + } + newContacts.value = contacts.value.filter(c => !readContacts.value.includes(c.id)); + } + + async function markContactAsRead(contactId) { + try { + await axios.post('/api/users/mark-contact-read', { contactId }); + if (!readContacts.value.includes(contactId)) { + readContacts.value.push(contactId); + updateNewContacts(); + } + } catch (e) {} + } + + async function fetchReadStatus() { + try { + const { data } = await axios.get('/api/messages/read-status'); + lastReadMessageDate.value = data || {}; + } catch (e) { + lastReadMessageDate.value = {}; } - if (all.length) lastContactId = Math.max(...all.map(c => c.id)); } async function fetchMessages() { const all = await getAllMessages(); messages.value = all; - if (lastMessageId) { - newMessages.value = all.filter(m => m.id > lastMessageId); - } else { - newMessages.value = []; - } - if (all.length) lastMessageId = Math.max(...all.map(m => m.id)); + filterNewMessages(); } - onMounted(() => { - fetchContacts(); - fetchMessages(); + function markMessagesAsRead() { + if (messages.value.length) { + lastMessageDate = Math.max(...messages.value.map(m => new Date(m.created_at).getTime())); + newMessages.value = []; + } + } + + async function markMessagesAsReadForUser(userId) { + // Найти максимальный created_at для сообщений этого пользователя + const userMessages = messages.value.filter(m => m.user_id === userId && m.sender_type === 'user'); + if (userMessages.length) { + const maxDate = Math.max(...userMessages.map(m => new Date(m.created_at).getTime())); + const maxDateISO = new Date(maxDate).toISOString(); + try { + await axios.post('/api/messages/mark-read', { userId, lastReadAt: maxDateISO }); + lastReadMessageDate.value[userId] = maxDateISO; + } catch (e) {} + } + filterNewMessages(); + } + + function filterNewMessages() { + newMessages.value = messages.value.filter(m => { + if (m.sender_type !== 'user') return false; + const lastRead = lastReadMessageDate.value[m.user_id]; + if (!lastRead) return true; + return new Date(m.created_at).getTime() > new Date(lastRead).getTime(); + }); + } + + function setupWebSocket() { ws = new WebSocket('ws://localhost:8000'); ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.type === 'contacts-updated') fetchContacts(); - if (data.type === 'messages-updated') fetchMessages(); - } catch (e) {} + const data = JSON.parse(event.data); + if (data.type === 'contacts-updated') { + fetchContacts(); + } + if (data.type === 'messages-updated') { + fetchMessages(); + } }; + } + + onMounted(async () => { + await fetchContactsReadStatus(); + await fetchContacts(); + await fetchReadStatus(); + await fetchMessages(); + setupWebSocket(); + }); + onUnmounted(() => { + if (ws) ws.close(); }); - onUnmounted(() => { if (ws) ws.close(); }); - - function markContactsAsRead() { newContacts.value = []; } - function markMessagesAsRead() { newMessages.value = []; } - - return { contacts, messages, newContacts, newMessages, markContactsAsRead, markMessagesAsRead }; + return { + contacts, + newContacts, + messages, + newMessages, + markContactAsRead, + markMessagesAsRead, + markMessagesAsReadForUser, + readUserIds + }; } \ No newline at end of file diff --git a/frontend/src/services/contactsService.js b/frontend/src/services/contactsService.js index 3a6c73d..24b2419 100644 --- a/frontend/src/services/contactsService.js +++ b/frontend/src/services/contactsService.js @@ -29,7 +29,7 @@ export default { } return null; } -}; +}; export async function getContacts() { const res = await fetch('/api/users'); diff --git a/frontend/src/services/messagesService.js b/frontend/src/services/messagesService.js index b38c5ea..eb75cc9 100644 --- a/frontend/src/services/messagesService.js +++ b/frontend/src/services/messagesService.js @@ -6,7 +6,7 @@ export default { const { data } = await axios.get(`/api/messages?userId=${userId}`); return data; } -}; +}; export async function getAllMessages() { const { data } = await axios.get('/api/messages'); diff --git a/frontend/src/views/ContactsView.vue b/frontend/src/views/ContactsView.vue index 80051ec..858a0d4 100644 --- a/frontend/src/views/ContactsView.vue +++ b/frontend/src/views/ContactsView.vue @@ -1,19 +1,11 @@ @@ -22,13 +14,11 @@ import { ref } from 'vue'; import { useRouter } from 'vue-router'; import BaseLayout from '../components/BaseLayout.vue'; import ContactTable from '../components/ContactTable.vue'; -import MessagesTable from '../components/MessagesTable.vue'; import { useContactsAndMessagesWebSocket } from '../composables/useContactsWebSocket'; -const tab = ref('all'); const { contacts, newContacts, newMessages, - markContactsAsRead, markMessagesAsRead + markContactsAsRead, markMessagesAsReadForUser, markContactAsRead } = useContactsAndMessagesWebSocket(); const router = useRouter(); @@ -42,26 +32,13 @@ function goBack() {
{{ contact.name || '-' }} {{ contact.email || '-' }} {{ contact.telegram || '-' }} {{ contact.wallet || '-' }}{{ formatDate(contact.created_at) }}{{ contact.created_at ? new Date(contact.created_at).toLocaleString() : '-' }} + ✉️