From 584ff401ad96e832801b8a9da965990b5a396197 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 11 Jul 2025 16:45: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 | 6 - .../034_create_tags_and_user_tags.sql | 13 - .../044_add_is_blocked_to_users.sql | 4 +- .../045_create_user_table_relations.sql | 17 + .../046_add_placeholder_to_user_columns.sql | 3 + .../migrations/046_create_user_tag_links.sql | 13 + .../migrations/047_create_user_tag_links.sql | 13 + backend/routes/chat.js | 6 + backend/routes/messages.js | 5 + backend/routes/rag.js | 5 +- backend/routes/tables.js | 184 ++++- backend/routes/tags.js | 61 -- backend/routes/tagsInit.js | 29 - backend/routes/userTags.js | 91 --- backend/routes/users.js | 64 +- backend/services/emailBot.js | 9 + backend/services/ragService.js | 102 ++- backend/services/telegramBot.js | 10 + backend/tests/ragService.test.js | 29 - backend/tests/ragServiceFull.test.js | 23 +- backend/utils/userUtils.js | 9 + backend/wsHub.js | 2 +- frontend/nginx-optimized.conf | 6 + frontend/nginx.conf | 6 + frontend/src/components/ContactTable.vue | 47 +- .../components/ai-assistant/RuleEditor.vue | 1 - .../ai-assistant/SystemMonitoring.vue | 1 - frontend/src/components/tables/TableCell.vue | 613 ++++++++++++++- .../src/components/tables/TagsTableView.vue | 285 ------- .../src/components/tables/UserTableView.vue | 706 +++++++++++------- .../src/components/tables/UserTablesList.vue | 5 +- frontend/src/router/index.js | 5 - frontend/src/services/contactsService.js | 18 +- frontend/src/services/tablesService.js | 7 +- .../src/views/contacts/ContactDetailsView.vue | 194 +++-- .../views/settings/AI/AiAssistantSettings.vue | 86 +++ frontend/src/views/tables/EditTableView.vue | 6 +- frontend/src/views/tables/TablesListView.vue | 1 - .../src/views/tables/TagsTableViewPage.vue | 10 - frontend/vite.config.js | 9 + md/rag-assistant-details.md | 95 +++ md/user-tables-relation-task.md | 150 ++++ 42 files changed, 1945 insertions(+), 1004 deletions(-) delete mode 100644 backend/db/migrations/034_create_tags_and_user_tags.sql create mode 100644 backend/db/migrations/045_create_user_table_relations.sql create mode 100644 backend/db/migrations/046_add_placeholder_to_user_columns.sql create mode 100644 backend/db/migrations/046_create_user_tag_links.sql create mode 100644 backend/db/migrations/047_create_user_tag_links.sql delete mode 100644 backend/routes/tags.js delete mode 100644 backend/routes/tagsInit.js delete mode 100644 backend/routes/userTags.js create mode 100644 backend/utils/userUtils.js delete mode 100644 frontend/src/components/tables/TagsTableView.vue delete mode 100644 frontend/src/views/tables/TagsTableViewPage.vue create mode 100644 md/rag-assistant-details.md create mode 100644 md/user-tables-relation-task.md diff --git a/backend/app.js b/backend/app.js index e299e7a..4bf7056 100644 --- a/backend/app.js +++ b/backend/app.js @@ -12,9 +12,6 @@ const aiAssistant = require('./services/ai-assistant'); // Добавляем и const fs = require('fs'); const path = require('path'); const messagesRoutes = require('./routes/messages'); -const userTagsRoutes = require('./routes/userTags'); -const tagsInitRoutes = require('./routes/tagsInit'); -const tagsRoutes = require('./routes/tags'); const ragRoutes = require('./routes/rag'); // Новый роут для RAG-ассистента const monitoringRoutes = require('./routes/monitoring'); @@ -177,7 +174,6 @@ app.use((req, res, next) => { app.use('/api/tables', tablesRoutes); // ДОЛЖНО БЫТЬ ВЫШЕ! // app.use('/api', identitiesRoutes); app.use('/api/auth', authRoutes); -app.use('/api/users', userTagsRoutes); app.use('/api/users', usersRoutes); app.use('/api/chat', chatRoutes); app.use('/api/admin', adminRoutes); @@ -187,8 +183,6 @@ app.use('/api/geocoding', geocodingRoutes); // Добавленное испол app.use('/api/dle', dleRoutes); // Добавляем маршрут DLE app.use('/api/settings', settingsRoutes); // Добавляем маршрут настроек app.use('/api/messages', messagesRoutes); -app.use('/api/tags', tagsInitRoutes); -app.use('/api/tags', tagsRoutes); app.use('/api/identities', identitiesRoutes); app.use('/api/rag', ragRoutes); // Подключаем роут app.use('/api/monitoring', monitoringRoutes); diff --git a/backend/db/migrations/034_create_tags_and_user_tags.sql b/backend/db/migrations/034_create_tags_and_user_tags.sql deleted file mode 100644 index 668c0d5..0000000 --- a/backend/db/migrations/034_create_tags_and_user_tags.sql +++ /dev/null @@ -1,13 +0,0 @@ --- Создание справочника тегов -CREATE TABLE IF NOT EXISTS tags ( - id SERIAL PRIMARY KEY, - name VARCHAR(64) NOT NULL UNIQUE, - description TEXT -); - --- Создание связующей таблицы "пользователь — тег" -CREATE TABLE IF NOT EXISTS user_tags ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, tag_id) -); diff --git a/backend/db/migrations/044_add_is_blocked_to_users.sql b/backend/db/migrations/044_add_is_blocked_to_users.sql index 6be4e48..2807473 100644 --- a/backend/db/migrations/044_add_is_blocked_to_users.sql +++ b/backend/db/migrations/044_add_is_blocked_to_users.sql @@ -1,3 +1,3 @@ -- Добавление поля is_blocked и blocked_at для блокировки пользователя -ALTER TABLE users ADD COLUMN is_blocked boolean NOT NULL DEFAULT false; -ALTER TABLE users ADD COLUMN blocked_at timestamp; \ No newline at end of file +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_blocked BOOLEAN DEFAULT FALSE; +ALTER TABLE users ADD COLUMN IF NOT EXISTS blocked_at timestamp; \ No newline at end of file diff --git a/backend/db/migrations/045_create_user_table_relations.sql b/backend/db/migrations/045_create_user_table_relations.sql new file mode 100644 index 0000000..418b94d --- /dev/null +++ b/backend/db/migrations/045_create_user_table_relations.sql @@ -0,0 +1,17 @@ +-- Миграция: универсальная таблица связей для relation/reference/lookup/multiselect + +CREATE TABLE IF NOT EXISTS user_table_relations ( + id SERIAL PRIMARY KEY, + from_row_id INTEGER NOT NULL REFERENCES user_rows(id) ON DELETE CASCADE, -- строка-источник + column_id INTEGER NOT NULL REFERENCES user_columns(id) ON DELETE CASCADE, -- столбец, в котором хранится связь + to_table_id INTEGER NOT NULL REFERENCES user_tables(id) ON DELETE CASCADE, -- таблица-назначение + to_row_id INTEGER NOT NULL REFERENCES user_rows(id) ON DELETE CASCADE, -- строка-назначение + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Индексы для ускорения поиска и фильтрации +CREATE INDEX IF NOT EXISTS idx_user_table_relations_from_row ON user_table_relations(from_row_id); +CREATE INDEX IF NOT EXISTS idx_user_table_relations_column ON user_table_relations(column_id); +CREATE INDEX IF NOT EXISTS idx_user_table_relations_to_table ON user_table_relations(to_table_id); +CREATE INDEX IF NOT EXISTS idx_user_table_relations_to_row ON user_table_relations(to_row_id); \ No newline at end of file diff --git a/backend/db/migrations/046_add_placeholder_to_user_columns.sql b/backend/db/migrations/046_add_placeholder_to_user_columns.sql new file mode 100644 index 0000000..ac37c17 --- /dev/null +++ b/backend/db/migrations/046_add_placeholder_to_user_columns.sql @@ -0,0 +1,3 @@ +-- Миграция: добавление поля placeholder в user_columns + +ALTER TABLE user_columns ADD COLUMN IF NOT EXISTS placeholder VARCHAR(255) UNIQUE; \ No newline at end of file diff --git a/backend/db/migrations/046_create_user_tag_links.sql b/backend/db/migrations/046_create_user_tag_links.sql new file mode 100644 index 0000000..ec92adf --- /dev/null +++ b/backend/db/migrations/046_create_user_tag_links.sql @@ -0,0 +1,13 @@ +-- Миграция: таблица связей пользователей и тегов + +CREATE TABLE IF NOT EXISTS 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) +); + +-- Индексы для ускорения поиска +CREATE INDEX IF NOT EXISTS idx_user_tag_links_user_id ON user_tag_links(user_id); +CREATE INDEX IF NOT EXISTS idx_user_tag_links_tag_id ON user_tag_links(tag_id); \ No newline at end of file diff --git a/backend/db/migrations/047_create_user_tag_links.sql b/backend/db/migrations/047_create_user_tag_links.sql new file mode 100644 index 0000000..ec92adf --- /dev/null +++ b/backend/db/migrations/047_create_user_tag_links.sql @@ -0,0 +1,13 @@ +-- Миграция: таблица связей пользователей и тегов + +CREATE TABLE IF NOT EXISTS 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) +); + +-- Индексы для ускорения поиска +CREATE INDEX IF NOT EXISTS idx_user_tag_links_user_id ON user_tag_links(user_id); +CREATE INDEX IF NOT EXISTS idx_user_tag_links_tag_id ON user_tag_links(tag_id); \ No newline at end of file diff --git a/backend/routes/chat.js b/backend/routes/chat.js index c4e2de0..affc986 100644 --- a/backend/routes/chat.js +++ b/backend/routes/chat.js @@ -8,6 +8,7 @@ const { requireAuth } = require('../middleware/auth'); const crypto = require('crypto'); const aiAssistantSettingsService = require('../services/aiAssistantSettingsService'); const aiAssistantRulesService = require('../services/aiAssistantRulesService'); +const { isUserBlocked } = require('../utils/userUtils'); // Настройка multer для обработки файлов в памяти const storage = multer.memoryStorage(); @@ -423,6 +424,11 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re const userMessage = userMessageResult.rows[0]; logger.info('User message saved', { messageId: userMessage.id, conversationId }); + if (await isUserBlocked(userId)) { + logger.info(`[Chat] Пользователь ${userId} заблокирован — ответ ИИ не отправляется.`); + return; + } + // --- Новая логика автоответа ИИ по RAG --- let aiMessage = null; let shouldGenerateAiReply = true; diff --git a/backend/routes/messages.js b/backend/routes/messages.js index 874e127..2120973 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -4,6 +4,7 @@ const db = require('../db'); const { broadcastMessagesUpdate } = require('../wsHub'); const telegramBot = require('../services/telegramBot'); const emailBot = new (require('../services/emailBot'))(); +const { isUserBlocked } = require('../utils/userUtils'); // GET /api/messages?userId=123 router.get('/', async (req, res) => { @@ -44,6 +45,10 @@ router.get('/', async (req, res) => { 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 { + // Проверка блокировки пользователя + if (await isUserBlocked(user_id)) { + return res.status(403).json({ error: 'Пользователь заблокирован. Сообщение не принимается.' }); + } // Проверка наличия идентификатора для выбранного канала if (channel === 'email') { const emailIdentity = await db.getQuery()( diff --git a/backend/routes/rag.js b/backend/routes/rag.js index 4b69073..aed7aef 100644 --- a/backend/routes/rag.js +++ b/backend/routes/rag.js @@ -7,9 +7,9 @@ router.get('/health', (req, res) => { }); router.post('/answer', async (req, res) => { - const { tableId, question, userTags, product, systemPrompt, priority, date, rules, history, model, language } = req.body; + const { tableId, question, product, systemPrompt, priority, date, rules, history, model, language } = req.body; try { - const ragResult = await ragAnswer({ tableId, userQuestion: question, userTags, product }); + const ragResult = await ragAnswer({ tableId, userQuestion: question, product }); const llmResponse = await generateLLMResponse({ userQuestion: question, context: ragResult.context, @@ -17,7 +17,6 @@ router.post('/answer', async (req, res) => { objectionAnswer: ragResult.objectionAnswer, answer: ragResult.answer, systemPrompt, - userTags: userTags?.join ? userTags.join(', ') : userTags, product, priority: priority || ragResult.priority, date: date || ragResult.date, diff --git a/backend/routes/tables.js b/backend/routes/tables.js index 33d5ad2..e2eab63 100644 --- a/backend/routes/tables.js +++ b/backend/routes/tables.js @@ -50,6 +50,30 @@ router.get('/:id', async (req, res, next) => { } }); +// Вспомогательная функция для генерации плейсхолдера +function generatePlaceholder(name, existingPlaceholders = []) { + // Транслитерация (упрощённая) + const cyrillicToLatinMap = { + а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya' + }; + let translit = name.toLowerCase().split('').map(ch => { + if (cyrillicToLatinMap[ch]) return cyrillicToLatinMap[ch]; + if (/[a-z0-9]/.test(ch)) return ch; + if (ch === ' ') return '_'; + if (ch === '-') return '_'; + return ''; + }).join(''); + translit = translit.replace(/_+/g, '_').replace(/^_+|_+$/g, ''); + let base = translit; + let candidate = base; + let i = 1; + while (existingPlaceholders.includes(candidate)) { + candidate = `${base}_${i}`; + i++; + } + return candidate; +} + // Добавить столбец (доступно всем) router.post('/:id/columns', async (req, res, next) => { try { @@ -62,9 +86,13 @@ router.post('/:id/columns', async (req, res, next) => { if (purpose) { finalOptions.purpose = purpose; } + // Получаем уже существующие плейсхолдеры в таблице + const existing = (await db.getQuery()('SELECT placeholder FROM user_columns WHERE table_id = $1', [tableId])).rows; + const existingPlaceholders = existing.map(c => c.placeholder).filter(Boolean); + const placeholder = generatePlaceholder(name, existingPlaceholders); const result = await db.getQuery()( - 'INSERT INTO user_columns (table_id, name, type, options, "order") VALUES ($1, $2, $3, $4, $5) RETURNING *', - [tableId, name, type, finalOptions ? JSON.stringify(finalOptions) : null, order || 0] + 'INSERT INTO user_columns (table_id, name, type, options, "order", placeholder) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', + [tableId, name, type, finalOptions ? JSON.stringify(finalOptions) : null, order || 0, placeholder] ); res.json(result.rows[0]); } catch (err) { @@ -80,6 +108,7 @@ router.post('/:id/rows', async (req, res, next) => { 'INSERT INTO user_rows (table_id) VALUES ($1) RETURNING *', [tableId] ); + console.log('[DEBUG][addRow] result.rows[0]:', result.rows[0]); // Получаем все строки и значения для upsert const rows = (await db.getQuery()('SELECT r.id as row_id, c.value as text, c2.value as answer FROM user_rows r LEFT JOIN user_cell_values c ON c.row_id = r.id AND c.column_id = 1 LEFT JOIN user_cell_values c2 ON c2.row_id = r.id AND c2.column_id = 2 WHERE r.table_id = $1', [tableId])).rows; const upsertRows = rows.filter(r => r.row_id && r.text).map(r => ({ row_id: r.row_id, text: r.text, metadata: { answer: r.answer } })); @@ -87,17 +116,18 @@ router.post('/:id/rows', async (req, res, next) => { if (upsertRows.length > 0) { await vectorSearchClient.upsert(tableId, upsertRows); } + console.log('[DEBUG][addRow] res.json:', result.rows[0]); res.json(result.rows[0]); } catch (err) { next(err); } }); -// Получить строки таблицы с фильтрацией по продукту и тегам +// Получить строки таблицы с фильтрацией по продукту, тегам и связям router.get('/:id/rows', async (req, res, next) => { try { const tableId = req.params.id; - const { product, tags } = req.query; // tags = "B2B,VIP" + const { product, tags, ...relationFilters } = req.query; // tags = "B2B,VIP", relation_{colId}=rowId // Получаем все столбцы, строки и значения ячеек const columns = (await db.getQuery()('SELECT * FROM user_columns WHERE table_id = $1', [tableId])).rows; const rows = (await db.getQuery()('SELECT * FROM user_rows WHERE table_id = $1', [tableId])).rows; @@ -118,7 +148,7 @@ router.get('/:id/rows', async (req, res, next) => { }; }); - // Фильтрация на сервере + // Фильтрация на сервере (старое) let filtered = data; if (product) { filtered = filtered.filter(r => r.product === product); @@ -130,6 +160,33 @@ router.get('/:id/rows', async (req, res, next) => { ); } + // Новая фильтрация по relation/multiselect/lookup + const relationFilterKeys = Object.keys(relationFilters).filter(k => k.startsWith('relation_') || k.startsWith('multiselect_') || k.startsWith('lookup_')); + if (relationFilterKeys.length > 0) { + // Получаем все связи для строк этой таблицы + const rowIds = filtered.map(r => r.id); + const rels = (await db.getQuery()( + 'SELECT * FROM user_table_relations WHERE from_row_id = ANY($1)', [rowIds] + )).rows; + for (const key of relationFilterKeys) { + const [type, colId] = key.split('_'); + const filterVals = (relationFilters[key] || '').split(',').map(v => v.trim()).filter(Boolean); + if (!colId || !filterVals.length) continue; + filtered = filtered.filter(r => { + const relsForRow = rels.filter(rel => String(rel.from_row_id) === String(r.id) && String(rel.column_id) === colId); + if (type === 'relation' || type === 'lookup') { + // Обычная связь: хотя бы одна связь с нужным to_row_id + return relsForRow.some(rel => filterVals.includes(String(rel.to_row_id))); + } else if (type === 'multiselect') { + // Мультивыбор: все значения должны быть среди связей + const rowVals = relsForRow.map(rel => String(rel.to_row_id)); + return filterVals.every(val => rowVals.includes(val)); + } + return true; + }); + } + } + res.json(filtered); } catch (err) { next(err); @@ -237,13 +294,21 @@ router.delete('/column/:columnId', async (req, res, next) => { router.patch('/column/:columnId', async (req, res, next) => { try { const columnId = req.params.columnId; - const { name, type, options, order } = req.body; - + const { name, type, options, order, placeholder } = req.body; + // Получаем table_id для проверки уникальности плейсхолдера + const colInfo = (await db.getQuery()('SELECT table_id, name FROM user_columns WHERE id = $1', [columnId])).rows[0]; + if (!colInfo) return res.status(404).json({ error: 'Column not found' }); + let newPlaceholder = placeholder; + if (name !== undefined && !placeholder) { + // Если имя меняется и плейсхолдер не передан — генерируем новый + const existing = (await db.getQuery()('SELECT placeholder FROM user_columns WHERE table_id = $1 AND id != $2', [colInfo.table_id, columnId])).rows; + const existingPlaceholders = existing.map(c => c.placeholder).filter(Boolean); + newPlaceholder = generatePlaceholder(name, existingPlaceholders); + } // Построение динамического запроса const updates = []; const values = []; let paramIndex = 1; - if (name !== undefined) { updates.push(`name = $${paramIndex++}`); values.push(name); @@ -260,21 +325,20 @@ router.patch('/column/:columnId', async (req, res, next) => { updates.push(`"order" = $${paramIndex++}`); values.push(order); } - + if (newPlaceholder !== undefined) { + updates.push(`placeholder = $${paramIndex++}`); + values.push(newPlaceholder); + } if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } - updates.push(`updated_at = NOW()`); values.push(columnId); - const query = `UPDATE user_columns SET ${updates.join(', ')} WHERE id = $${paramIndex} RETURNING *`; const result = await db.getQuery()(query, values); - if (result.rows.length === 0) { return res.status(404).json({ error: 'Column not found' }); } - res.json(result.rows[0]); } catch (err) { next(err); @@ -383,4 +447,98 @@ router.delete('/:id', requireAuth, async (req, res, next) => { } }); +// Получить связи для строки (relation/multiselect/lookup) +router.get('/:tableId/row/:rowId/relations', async (req, res, next) => { + try { + const { tableId, rowId } = req.params; + const relations = (await db.getQuery()( + 'SELECT * FROM user_table_relations WHERE from_row_id = $1', + [rowId] + )).rows; + res.json(relations); + } catch (err) { + next(err); + } +}); + +// Добавить связь (relation/multiselect/lookup) +router.post('/:tableId/row/:rowId/relations', async (req, res, next) => { + try { + const { tableId, rowId } = req.params; + const { column_id, to_table_id, to_row_id } = req.body; + const result = await db.getQuery()( + `INSERT INTO user_table_relations (from_row_id, column_id, to_table_id, to_row_id) + VALUES ($1, $2, $3, $4) RETURNING *`, + [rowId, column_id, to_table_id, to_row_id] + ); + res.json(result.rows[0]); + } catch (err) { + next(err); + } +}); + +// Удалить связь +router.delete('/:tableId/row/:rowId/relations/:relationId', async (req, res, next) => { + try { + const { relationId } = req.params; + await db.getQuery()('DELETE FROM user_table_relations WHERE id = $1', [relationId]); + res.json({ success: true }); + } catch (err) { + next(err); + } +}); + +// --- Массовое обновление связей для multiselect-relation --- +router.post('/:tableId/row/:rowId/multirelations', async (req, res, next) => { + try { + const { tableId, rowId } = req.params; + const { column_id, to_table_id, to_row_ids } = req.body; // to_row_ids: массив id + if (!Array.isArray(to_row_ids)) return res.status(400).json({ error: 'to_row_ids должен быть массивом' }); + // Удаляем старые связи для этой строки/столбца + await db.getQuery()('DELETE FROM user_table_relations WHERE from_row_id = $1 AND column_id = $2', [rowId, column_id]); + // Добавляем новые связи + for (const to_row_id of to_row_ids) { + await db.getQuery()( + `INSERT INTO user_table_relations (from_row_id, column_id, to_table_id, to_row_id) + VALUES ($1, $2, $3, $4)`, + [rowId, column_id, to_table_id, to_row_id] + ); + } + res.json({ success: true }); + } catch (err) { + next(err); + } +}); + +// Получить плейсхолдеры для всех столбцов таблицы +router.get('/:id/placeholders', async (req, res, next) => { + try { + const tableId = req.params.id; + const columns = (await db.getQuery()('SELECT id, name, placeholder FROM user_columns WHERE table_id = $1', [tableId])).rows; + res.json(columns.map(col => ({ + id: col.id, + name: col.name, + placeholder: col.placeholder + }))); + } catch (err) { + next(err); + } +}); + +// Получить все плейсхолдеры по всем пользовательским таблицам +router.get('/placeholders/all', async (req, res, next) => { + try { + const result = await db.getQuery()(` + SELECT c.id as column_id, c.name as column_name, c.placeholder, t.id as table_id, t.name as table_name + FROM user_columns c + JOIN user_tables t ON c.table_id = t.id + WHERE c.placeholder IS NOT NULL AND c.placeholder != '' + ORDER BY t.id, c.id + `); + res.json(result.rows); + } catch (err) { + next(err); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/routes/tags.js b/backend/routes/tags.js deleted file mode 100644 index 3927573..0000000 --- a/backend/routes/tags.js +++ /dev/null @@ -1,61 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const db = require('../db'); - -// Получить все теги -router.get('/', async (req, res) => { - console.log('GET /api/tags'); - try { - const query = db.getQuery(); - const { rows } = await query('SELECT * FROM tags ORDER BY name'); - res.json(rows); - } catch (e) { - console.error('Ошибка в /api/tags:', e); - res.status(500).json({ error: e.message, stack: e.stack }); - } -}); - -// Создать тег -router.post('/', async (req, res) => { - console.log('POST /api/tags', req.body); - try { - const { name, description } = req.body; - const query = db.getQuery(); - const result = await query( - 'INSERT INTO tags (name, description) VALUES ($1, $2) RETURNING *', - [name, description] - ); - const row = result && result.rows && result.rows[0] ? result.rows[0] : null; - if (row) { - res.json(row); - } else { - res.status(500).json({ error: 'Не удалось создать тег', result }); - } - } catch (e) { - console.error('Ошибка в /api/tags (POST):', e); - res.status(500).json({ error: e.message, stack: e.stack }); - } -}); - -// Удалить тег и все его связи с пользователями -router.delete('/:tagId', async (req, res) => { - console.log('DELETE /api/tags/:id', req.params.tagId); - try { - const tagId = req.params.tagId; - const query = db.getQuery(); - // Сначала удаляем связи user_tags - await query('DELETE FROM user_tags WHERE tag_id = $1', [tagId]); - // Затем удаляем сам тег - const result = await query('DELETE FROM tags WHERE id = $1 RETURNING *', [tagId]); - if (result.rowCount > 0) { - res.json({ success: true }); - } else { - res.status(404).json({ error: 'Тег не найден' }); - } - } catch (e) { - console.error('Ошибка в /api/tags/:id (DELETE):', e); - res.status(500).json({ error: e.message, stack: e.stack }); - } -}); - -module.exports = router; \ No newline at end of file diff --git a/backend/routes/tagsInit.js b/backend/routes/tagsInit.js deleted file mode 100644 index c9a5ed3..0000000 --- a/backend/routes/tagsInit.js +++ /dev/null @@ -1,29 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const db = require('../db'); - -// Инициализация таблиц тегов -router.post('/init', async (req, res) => { - console.log('POST /api/tags/init'); - try { - const query = db.getQuery(); - await query(` - CREATE TABLE IF NOT EXISTS tags ( - id SERIAL PRIMARY KEY, - name VARCHAR(64) NOT NULL UNIQUE, - description TEXT - ); - CREATE TABLE IF NOT EXISTS user_tags ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, tag_id) - ); - `); - res.json({ ok: true }); - } catch (e) { - console.error('Ошибка в /api/tags/init:', e); - res.status(500).json({ error: e.message, stack: e.stack }); - } -}); - -module.exports = router; \ No newline at end of file diff --git a/backend/routes/userTags.js b/backend/routes/userTags.js deleted file mode 100644 index 84bb2be..0000000 --- a/backend/routes/userTags.js +++ /dev/null @@ -1,91 +0,0 @@ -const express = require('express'); -const router = express.Router(); -const db = require('../db'); - -// Инициализация таблиц тегов (если нужно) -router.post('/init', async (req, res) => { - console.log('POST /api/users/tags/init'); - try { - const query = db.getQuery(); - await query(` - CREATE TABLE IF NOT EXISTS tags ( - id SERIAL PRIMARY KEY, - name VARCHAR(64) NOT NULL UNIQUE, - description TEXT - ); - CREATE TABLE IF NOT EXISTS user_tags ( - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (user_id, tag_id) - ); - `); - res.json({ ok: true }); - } catch (e) { - console.error('Ошибка в /api/users/tags/init:', e); - res.status(500).json({ error: e.message, stack: e.stack }); - } -}); - -// --- Работа с тегами пользователя --- - -// Получить теги пользователя -router.get('/:userId/tags', async (req, res) => { - console.log('GET /api/users/:id/tags', req.params.userId); - try { - const userId = req.params.userId; - const query = db.getQuery(); - const result = await query( - `SELECT t.* FROM tags t - JOIN user_tags ut ON ut.tag_id = t.id - WHERE ut.user_id = $1`, - [userId] - ); - const rows = result && result.rows ? result.rows : []; - res.json(rows); - } catch (e) { - console.error('Ошибка в /api/users/:id/tags (GET):', e); - res.status(500).json({ error: e.message, stack: e.stack }); - } -}); - -// Добавить тег пользователю -router.post('/:userId/tags', async (req, res) => { - console.log('POST /api/users/:id/tags', req.params.userId, req.body); - try { - const userId = req.params.userId; - const { tag_id } = req.body; - const query = db.getQuery(); - await query( - 'INSERT INTO user_tags (user_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', - [userId, tag_id] - ); - res.json({ ok: true }); - } catch (e) { - console.error('Ошибка в /api/users/:id/tags (POST):', e); - res.status(500).json({ error: e.message, stack: e.stack }); - } -}); - -// Удалить тег у пользователя -router.delete('/:userId/tags/:tagId', async (req, res) => { - console.log('DELETE /api/users/:id/tags/:tagId', req.params.userId, req.params.tagId); - try { - const userId = req.params.userId; - const tagId = req.params.tagId; - const query = db.getQuery(); - const result = await query( - 'DELETE FROM user_tags WHERE user_id = $1 AND tag_id = $2 RETURNING *', - [userId, tagId] - ); - if (result.rowCount > 0) { - res.json({ success: true }); - } else { - res.status(404).json({ error: 'Связь не найдена' }); - } - } catch (e) { - console.error('Ошибка в /api/users/:id/tags/:tagId (DELETE):', e); - res.status(500).json({ error: e.message, stack: e.stack }); - } -}); - -module.exports = router; \ No newline at end of file diff --git a/backend/routes/users.js b/backend/routes/users.js index cfedb6c..003920d 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -122,10 +122,10 @@ router.get('/', requireAuth, async (req, res, next) => { const tagIdArr = tagIds.split(',').map(Number).filter(Boolean); if (tagIdArr.length > 0) { sql += ` - JOIN user_tags ut ON ut.user_id = u.id - WHERE ut.tag_id = ANY($${idx++}) + JOIN user_tag_links utl ON utl.user_id = u.id + WHERE utl.tag_id = ANY($${idx++}) GROUP BY u.id - HAVING COUNT(DISTINCT ut.tag_id) = $${idx++} + HAVING COUNT(DISTINCT utl.tag_id) = $${idx++} `; params.push(tagIdArr); params.push(tagIdArr.length); @@ -321,7 +321,7 @@ router.get('/:id', async (req, res, next) => { try { const query = db.getQuery(); // Получаем пользователя - const userResult = await query('SELECT id, first_name, last_name, created_at, preferred_language FROM users WHERE id = $1', [userId]); + const userResult = await query('SELECT id, first_name, last_name, created_at, preferred_language, is_blocked FROM users WHERE id = $1', [userId]); if (userResult.rows.length === 0) { return res.status(404).json({ error: 'User not found' }); } @@ -339,7 +339,8 @@ router.get('/:id', async (req, res, next) => { telegram: identityMap.telegram || null, wallet: identityMap.wallet || null, created_at: user.created_at, - preferred_language: user.preferred_language || [] + preferred_language: user.preferred_language || [], + is_blocked: user.is_blocked || false }); } catch (e) { res.status(500).json({ error: e.message }); @@ -432,4 +433,57 @@ router.post('/import', requireAuth, async (req, res) => { } }); +// --- Работа с тегами пользователя через user_tag_links --- +// PATCH /api/users/:id/tags — установить теги пользователю +router.patch('/:id/tags', async (req, res) => { + const userId = Number(req.params.id); + const { tags } = req.body; // массив tagIds (id строк из таблицы тегов) + if (!Array.isArray(tags)) { + return res.status(400).json({ error: 'tags должен быть массивом' }); + } + try { + // Удаляем старые связи + await db.getQuery()('DELETE FROM user_tag_links WHERE user_id = $1', [userId]); + // Добавляем новые связи + for (const tagId of tags) { + await db.getQuery()( + 'INSERT INTO user_tag_links (user_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [userId, tagId] + ); + } + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/users/:id/tags — получить все теги пользователя +router.get('/:id/tags', async (req, res) => { + const userId = Number(req.params.id); + try { + const result = await db.getQuery()( + 'SELECT tag_id FROM user_tag_links WHERE user_id = $1', + [userId] + ); + res.json({ tags: result.rows.map(r => r.tag_id) }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// DELETE /api/users/:id/tags/:tagId — удалить тег у пользователя +router.delete('/:id/tags/:tagId', async (req, res) => { + const userId = Number(req.params.id); + const tagId = Number(req.params.tagId); + try { + await db.getQuery()( + 'DELETE FROM user_tag_links WHERE user_id = $1 AND tag_id = $2', + [userId, tagId] + ); + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + module.exports = router; diff --git a/backend/services/emailBot.js b/backend/services/emailBot.js index f0e0791..34841fa 100644 --- a/backend/services/emailBot.js +++ b/backend/services/emailBot.js @@ -11,6 +11,7 @@ const aiAssistant = require('./ai-assistant'); const { broadcastContactsUpdate } = require('../wsHub'); const aiAssistantSettingsService = require('./aiAssistantSettingsService'); const { ragAnswer, generateLLMResponse } = require('./ragService'); +const { isUserBlocked } = require('../utils/userUtils'); class EmailBotService { constructor() { @@ -142,6 +143,10 @@ class EmailBotService { const html = parsed.html || ''; // 1. Найти или создать пользователя const { userId, role } = await identityService.findOrCreateUserWithRole('email', fromEmail); + if (await isUserBlocked(userId)) { + logger.info(`Email от заблокированного пользователя ${userId} проигнорирован.`); + return; + } // 1.1 Найти или создать беседу let conversationResult = await db.getQuery()( 'SELECT * FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1', @@ -209,6 +214,10 @@ class EmailBotService { } else { aiResponse = await aiAssistant.getResponse(text, 'auto'); } + if (await isUserBlocked(userId)) { + logger.info(`[EmailBot] Пользователь ${userId} заблокирован — ответ ИИ не отправляется.`); + return; + } // 4. Сохранить ответ в БД с conversation_id await db.getQuery()( `INSERT INTO messages (user_id, conversation_id, sender_type, content, channel, role, direction, created_at, metadata) diff --git a/backend/services/ragService.js b/backend/services/ragService.js index 4d9e418..1669b99 100644 --- a/backend/services/ragService.js +++ b/backend/services/ragService.js @@ -19,7 +19,6 @@ async function getTableData(tableId) { const getColId = purpose => columns.find(col => col.options?.purpose === purpose)?.id; const questionColId = getColId('question'); const answerColId = getColId('answer'); - const userTagsColId = getColId('userTags'); const contextColId = getColId('context'); const productColId = getColId('product'); const priorityColId = getColId('priority'); @@ -28,7 +27,6 @@ async function getTableData(tableId) { console.log(`[RAG] Column IDs:`, { question: questionColId, answer: answerColId, - userTags: userTagsColId, context: contextColId, product: productColId, priority: priorityColId, @@ -41,9 +39,9 @@ async function getTableData(tableId) { id: row.id, question: cells.find(c => c.column_id === questionColId)?.value, answer: cells.find(c => c.column_id === answerColId)?.value, - userTags: cells.find(c => c.column_id === userTagsColId)?.value, context: cells.find(c => c.column_id === contextColId)?.value, - product: cells.find(c => c.column_id === productColId)?.value, + product: parseIfArray(cells.find(c => c.column_id === productColId)?.value), + userTags: parseIfArray(cells.find(c => c.column_id === getColId('userTags'))?.value), priority: cells.find(c => c.column_id === priorityColId)?.value, date: cells.find(c => c.column_id === dateColId)?.value, }; @@ -53,7 +51,7 @@ async function getTableData(tableId) { return data; } -async function ragAnswer({ tableId, userQuestion, userTags = [], product = null, threshold = 10 }) { +async function ragAnswer({ tableId, userQuestion, product = null, threshold = 10 }) { console.log(`[RAG] ragAnswer called: tableId=${tableId}, userQuestion="${userQuestion}"`); const data = await getTableData(tableId); @@ -65,24 +63,26 @@ async function ragAnswer({ tableId, userQuestion, userTags = [], product = null, id: row.id, question: row.question, answer: row.answer, - userTags: row.userTags, product: row.product }); }); const questions = data.map(row => row.question && typeof row.question === 'string' ? row.question.trim() : row.question); - const rowsForUpsert = data.map(row => ({ - row_id: row.id, - text: row.question, - metadata: { - answer: row.answer || null, - userTags: row.userTags || null, - context: row.context || null, - product: row.product || null, - priority: row.priority || null, - date: row.date || null - } - })); + // Фильтруем только строки с непустым вопросом (text) + const rowsForUpsert = data + .filter(row => row.id && row.question && String(row.question).trim().length > 0) + .map(row => ({ + row_id: row.id, + text: row.question, + metadata: { + answer: row.answer || null, + context: row.context || null, + product: row.product || [], + userTags: row.userTags || [], + priority: row.priority || null, + date: row.date || null + } + })); console.log(`[RAG] Prepared ${rowsForUpsert.length} rows for upsert`); console.log(`[RAG] First row:`, rowsForUpsert[0]); @@ -117,15 +117,9 @@ async function ragAnswer({ tableId, userQuestion, userTags = [], product = null, let filtered = results; console.log(`[RAG] Before filtering: ${filtered.length} results`); - if (userTags.length) { - console.log(`[RAG] Filtering by userTags:`, userTags); - filtered = filtered.filter(row => row.metadata.userTags && userTags.some(tag => row.metadata.userTags.includes(tag))); - console.log(`[RAG] After userTags filtering: ${filtered.length} results`); - } - if (product) { console.log(`[RAG] Filtering by product:`, product); - filtered = filtered.filter(row => row.metadata.product === product); + filtered = filtered.filter(row => Array.isArray(row.metadata.product) ? row.metadata.product.includes(product) : row.metadata.product === product); console.log(`[RAG] After product filtering: ${filtered.length} results`); } @@ -157,6 +151,50 @@ async function ragAnswer({ tableId, userQuestion, userTags = [], product = null, }; } +/** + * Загрузка всех плейсхолдеров и их значений из пользовательских таблиц + * Возвращает объект: { placeholder1: value1, placeholder2: value2, ... } + */ +async function getAllPlaceholdersWithValues() { + // Получаем все плейсхолдеры и их значения (берём первое значение для каждого плейсхолдера) + const result = await db.getQuery()(` + SELECT c.placeholder, cv.value + FROM user_columns c + JOIN user_cell_values cv ON c.id = cv.column_id + WHERE c.placeholder IS NOT NULL AND c.placeholder != '' + ORDER BY c.id, cv.id + `); + // Группируем по плейсхолдеру (берём первое значение) + const map = {}; + for (const row of result.rows) { + if (row.placeholder && !(row.placeholder in map)) { + map[row.placeholder] = row.value; + } + } + return map; +} + +/** + * Подставляет значения плейсхолдеров в строку (например, systemPrompt) + * Пример: "Добро пожаловать, {client_name}!" => "Добро пожаловать, ООО Ромашка!" + */ +function replacePlaceholders(str, placeholders) { + if (!str || typeof str !== 'string') return str; + return str.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => { + return key in placeholders ? placeholders[key] : match; + }); +} + +function parseIfArray(val) { + if (typeof val === 'string') { + try { + const arr = JSON.parse(val); + if (Array.isArray(arr)) return arr; + } catch {} + } + return Array.isArray(val) ? val : (val ? [val] : []); +} + async function generateLLMResponse({ userQuestion, context, @@ -200,20 +238,24 @@ async function generateLLMResponse({ prompt += `\n\nНайденный ответ: ${answer}`; } - if (userTags) { - prompt += `\n\nТеги: ${userTags}`; - } - if (product) { prompt += `\n\nПродукт: ${product}`; } + // --- ДОБАВЛЕНО: подстановка плейсхолдеров --- + let finalSystemPrompt = systemPrompt; + if (systemPrompt && systemPrompt.includes('{')) { + const placeholders = await getAllPlaceholdersWithValues(); + finalSystemPrompt = replacePlaceholders(systemPrompt, placeholders); + } + // --- КОНЕЦ ДОБАВЛЕНИЯ --- + // Получаем ответ от AI const llmResponse = await aiAssistant.getResponse( prompt, language || 'auto', history, - systemPrompt, + finalSystemPrompt, rules ); diff --git a/backend/services/telegramBot.js b/backend/services/telegramBot.js index 9820fbf..a147f75 100644 --- a/backend/services/telegramBot.js +++ b/backend/services/telegramBot.js @@ -10,6 +10,7 @@ const { checkAdminRole } = require('./admin-role'); const { broadcastContactsUpdate } = require('../wsHub'); const aiAssistantSettingsService = require('./aiAssistantSettingsService'); const { ragAnswer, generateLLMResponse } = require('./ragService'); +const { isUserBlocked } = require('../utils/userUtils'); let botInstance = null; let telegramSettingsCache = null; @@ -269,6 +270,10 @@ async function getBot() { const telegramId = ctx.from.id.toString(); // 1. Найти или создать пользователя const { userId, role } = await identityService.findOrCreateUserWithRole('telegram', telegramId); + if (await isUserBlocked(userId)) { + await ctx.reply('Вы заблокированы. Сообщения не принимаются.'); + return; + } // 1.1 Найти или создать беседу let conversationResult = await db.getQuery()( 'SELECT * FROM conversations WHERE user_id = $1 ORDER BY updated_at DESC, created_at DESC LIMIT 1', @@ -337,6 +342,11 @@ async function getBot() { ] ); + if (await isUserBlocked(userId)) { + logger.info(`[TelegramBot] Пользователь ${userId} заблокирован — ответ ИИ не отправляется.`); + return; + } + // 3. Получить ответ от ИИ (RAG + LLM) const aiSettings = await aiAssistantSettingsService.getSettings(); let ragTableId = null; diff --git a/backend/tests/ragService.test.js b/backend/tests/ragService.test.js index f072c51..a496877 100644 --- a/backend/tests/ragService.test.js +++ b/backend/tests/ragService.test.js @@ -34,19 +34,6 @@ describe('vectorSearchClient integration (vector-search)', () => { } }); - it('Поиск с фильтрацией по тегу (должен найти FAISS)', async () => { - const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое FAISS?', 3); - console.log('Результаты поиска FAISS:', results); - if (!results || results.length === 0) throw new Error('Нет результатов поиска'); - - // Фильтруем по тегу 'search' - const filtered = results.filter(r => r.metadata.userTags && r.metadata.userTags.includes('search')); - if (filtered.length === 0) throw new Error('Нет результатов с тегом search'); - if (filtered[0].metadata.answer !== 'Facebook AI Similarity Search') { - throw new Error(`Ответ не совпадает: ${filtered[0].metadata.answer}`); - } - }); - it('Поиск с фильтрацией по продукту (должен найти Ollama)', async () => { const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое Ollama?', 3); console.log('Результаты поиска Ollama:', results); @@ -60,22 +47,6 @@ describe('vectorSearchClient integration (vector-search)', () => { } }); - it('Комбинированная фильтрация (тег+продукт)', async () => { - const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое RAG?', 3); - console.log('Результаты поиска RAG:', results); - if (!results || results.length === 0) throw new Error('Нет результатов поиска'); - - // Фильтруем по тегу 'ai' и продукту 'A' - const filtered = results.filter(r => - r.metadata.userTags && r.metadata.userTags.includes('ai') && - r.metadata.product === 'A' - ); - if (filtered.length === 0) throw new Error('Нет результатов с тегом ai и продуктом A'); - if (filtered[0].metadata.answer !== 'Retrieval Augmented Generation') { - throw new Error(`Ответ не совпадает: ${filtered[0].metadata.answer}`); - } - }); - it('Проверка порога score', async () => { const results = await vectorSearch.search(TEST_TABLE_ID, 'Что такое Ollama?', 3); console.log('Результаты поиска с порогом:', results); diff --git a/backend/tests/ragServiceFull.test.js b/backend/tests/ragServiceFull.test.js index 0934575..ad101a3 100644 --- a/backend/tests/ragServiceFull.test.js +++ b/backend/tests/ragServiceFull.test.js @@ -21,7 +21,6 @@ describe('ragService full integration (DB + vector-search)', () => { const columns = [ { id: 'col_question', name: 'Question', type: 'text', purpose: 'question' }, { id: 'col_answer', name: 'Answer', type: 'text', purpose: 'answer' }, - { id: 'col_tags', name: 'Tags', type: 'text', purpose: 'userTags' }, { id: 'col_product', name: 'Product', type: 'text', purpose: 'product' } ]; @@ -35,9 +34,9 @@ describe('ragService full integration (DB + vector-search)', () => { // Создаем строки const rows = [ - { id: 'row_1', question: 'Что такое RAG?', answer: 'Retrieval Augmented Generation', tags: 'ai,ml', product: 'A' }, - { id: 'row_2', question: 'Что такое FAISS?', answer: 'Facebook AI Similarity Search', tags: 'ai,search', product: 'B' }, - { id: 'row_3', question: 'Что такое Ollama?', answer: 'Локальный inference LLM', tags: 'llm', product: 'A' } + { id: 'row_1', question: 'Что такое RAG?', answer: 'Retrieval Augmented Generation', product: 'A' }, + { id: 'row_2', question: 'Что такое FAISS?', answer: 'Facebook AI Similarity Search', product: 'B' }, + { id: 'row_3', question: 'Что такое Ollama?', answer: 'Локальный inference LLM', product: 'A' } ]; for (const row of rows) { @@ -56,7 +55,6 @@ describe('ragService full integration (DB + vector-search)', () => { `, [ row.id, 'col_question', row.question, row.id, 'col_answer', row.answer, - row.id, 'col_tags', row.tags, row.id, 'col_product', row.product ]); } @@ -104,20 +102,6 @@ describe('ragService full integration (DB + vector-search)', () => { } }); - it('Полная интеграция: фильтрация по тегу', async () => { - const result = await ragService.ragAnswer({ - tableId: TEST_TABLE_ID, - userQuestion: 'Что такое FAISS?', - userTags: ['search'] - }); - - console.log('Результат с фильтром по тегу:', result); - if (!result) throw new Error('Нет результата'); - if (result.answer !== 'Facebook AI Similarity Search') { - throw new Error(`Ответ не совпадает: ${result.answer}`); - } - }); - it('Полная интеграция: фильтрация по продукту', async () => { const result = await ragService.ragAnswer({ tableId: TEST_TABLE_ID, @@ -136,7 +120,6 @@ describe('ragService full integration (DB + vector-search)', () => { const result = await ragService.ragAnswer({ tableId: TEST_TABLE_ID, userQuestion: 'Что такое RAG?', - userTags: ['ai'], product: 'A' }); diff --git a/backend/utils/userUtils.js b/backend/utils/userUtils.js new file mode 100644 index 0000000..3d03d4c --- /dev/null +++ b/backend/utils/userUtils.js @@ -0,0 +1,9 @@ +const db = require('../db'); + +async function isUserBlocked(userId) { + if (!userId) return false; + const result = await db.getQuery()('SELECT is_blocked FROM users WHERE id = $1', [userId]); + return !!result.rows[0]?.is_blocked; +} + +module.exports = { isUserBlocked }; \ No newline at end of file diff --git a/backend/wsHub.js b/backend/wsHub.js index f6daa6c..3964907 100644 --- a/backend/wsHub.js +++ b/backend/wsHub.js @@ -4,7 +4,7 @@ let wss = null; const wsClients = new Set(); function initWSS(server) { - wss = new WebSocket.Server({ server }); + wss = new WebSocket.Server({ server, path: '/ws' }); wss.on('connection', (ws) => { wsClients.add(ws); ws.on('close', () => wsClients.delete(ws)); diff --git a/frontend/nginx-optimized.conf b/frontend/nginx-optimized.conf index b9e7f7a..a03417e 100644 --- a/frontend/nginx-optimized.conf +++ b/frontend/nginx-optimized.conf @@ -36,6 +36,12 @@ server { proxy_set_header Connection "upgrade"; } + # Запрет на доступ к чувствительным файлам + location ~* /(sendgrid\.env|env\.js|config\.js|aws-exports\.js|firebase-config\.js|firebase\.js|settings\.js|app-settings\.js|config\.json|credentials\.json|secrets\.json)$ { + deny all; + return 403; + } + # Основные страницы location / { proxy_pass http://localhost:9000; diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3989228..675df01 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -43,6 +43,12 @@ http { add_header Vary Accept-Encoding; } + # Запрет на доступ к чувствительным файлам + location ~* /(sendgrid\.env|env\.js|config\.js|aws-exports\.js|firebase-config\.js|firebase\.js|settings\.js|app-settings\.js|config\.json|credentials\.json|secrets\.json)$ { + deny all; + return 403; + } + # Основные файлы HTML location / { try_files $uri $uri/ /index.html; diff --git a/frontend/src/components/ContactTable.vue b/frontend/src/components/ContactTable.vue index 7aa2c4e..a23e89f 100644 --- a/frontend/src/components/ContactTable.vue +++ b/frontend/src/components/ContactTable.vue @@ -37,18 +37,17 @@ - + [] }, newContacts: { type: Array, default: () => [] }, @@ -117,8 +117,8 @@ const filterDateTo = ref(''); const filterNewMessages = ref(''); const filterBlocked = ref('all'); -// Теги -const allTags = ref([]); +// Новый фильтр тегов через мультисвязи +const availableTags = ref([]); const selectedTagIds = ref([]); const showImportModal = ref(false); @@ -128,13 +128,36 @@ const selectedIds = ref([]); const selectAll = ref(false); onMounted(async () => { - await loadTags(); await fetchContacts(); + await loadAvailableTags(); }); -async function loadTags() { - const res = await fetch('/api/tags'); - allTags.value = await res.json(); +async function loadAvailableTags() { + try { + // Получаем все пользовательские таблицы и ищем "Теги клиентов" + const tables = await tablesService.getTables(); + const tagsTable = tables.find(t => t.name === 'Теги клиентов'); + + if (tagsTable) { + // Загружаем данные таблицы тегов + const table = await tablesService.getTable(tagsTable.id); + const nameColumn = table.columns.find(col => col.name === 'Название') || table.columns[0]; + + if (nameColumn) { + // Формируем список тегов + availableTags.value = table.rows.map(row => { + const nameCell = table.cellValues.find(c => c.row_id === row.id && c.column_id === nameColumn.id); + return { + id: row.id, + name: nameCell ? nameCell.value : `Тег ${row.id}` + }; + }).filter(tag => tag.name.trim()); // Исключаем пустые названия + } + } + } catch (e) { + console.error('Ошибка загрузки тегов:', e); + availableTags.value = []; + } } function buildQuery() { @@ -158,10 +181,6 @@ async function fetchContacts() { contactsArray.value = data.contacts || []; } -function onTagsFilterChange() { - onAnyFilterChange(); -} - function onAnyFilterChange() { fetchContacts(); } @@ -173,7 +192,7 @@ function resetFilters() { filterDateTo.value = ''; filterNewMessages.value = ''; filterBlocked.value = 'all'; - selectedTagIds.value = []; + selectedTagIds.value = []; // Сбрасываем выбранные теги fetchContacts(); } diff --git a/frontend/src/components/ai-assistant/RuleEditor.vue b/frontend/src/components/ai-assistant/RuleEditor.vue index 85cc7cf..9c7d552 100644 --- a/frontend/src/components/ai-assistant/RuleEditor.vue +++ b/frontend/src/components/ai-assistant/RuleEditor.vue @@ -6,7 +6,6 @@
{{ error }}
diff --git a/frontend/src/components/ai-assistant/SystemMonitoring.vue b/frontend/src/components/ai-assistant/SystemMonitoring.vue index fa7efc2..beb8a40 100644 --- a/frontend/src/components/ai-assistant/SystemMonitoring.vue +++ b/frontend/src/components/ai-assistant/SystemMonitoring.vue @@ -136,7 +136,6 @@ const testRAG = async () => { const response = await axios.post('/rag/answer', { tableId: 28, question: ragQuestion.value, - userTags: [], product: null }); ragResult.value = { diff --git a/frontend/src/components/tables/TableCell.vue b/frontend/src/components/tables/TableCell.vue index 97650e4..7f07295 100644 --- a/frontend/src/components/tables/TableCell.vue +++ b/frontend/src/components/tables/TableCell.vue @@ -1,22 +1,75 @@