diff --git a/backend/app.js b/backend/app.js index ca6fc87..d707ad4 100644 --- a/backend/app.js +++ b/backend/app.js @@ -86,6 +86,7 @@ const countriesRoutes = require('./routes/countries'); // Добавляем и const russianClassifiersRoutes = require('./routes/russian-classifiers'); // Добавляем импорт российских классификаторов const ollamaRoutes = require('./routes/ollama'); // Добавляем импорт Ollama маршрутов const aiQueueRoutes = require('./routes/ai-queue'); // Добавляем импорт AI Queue маршрутов +const tagsRoutes = require('./routes/tags'); // Добавляем импорт маршрутов тегов const app = express(); @@ -210,6 +211,7 @@ app.use('/api/countries', countriesRoutes); // Добавляем маршрут app.use('/api/russian-classifiers', russianClassifiersRoutes); // Добавляем маршрут российских классификаторов app.use('/api/ollama', ollamaRoutes); // Добавляем маршрут Ollama app.use('/api/ai-queue', aiQueueRoutes); // Добавляем маршрут AI Queue +app.use('/api/tags', tagsRoutes); // Добавляем маршрут тегов app.use('/api/messages', messagesRoutes); app.use('/api/identities', identitiesRoutes); app.use('/api/rag', ragRoutes); // Подключаем роут diff --git a/backend/routes/tables.js b/backend/routes/tables.js index 90f0e92..3f35164 100644 --- a/backend/routes/tables.js +++ b/backend/routes/tables.js @@ -19,6 +19,18 @@ const { requireAuth } = require('../middleware/auth'); const vectorSearchClient = require('../services/vectorSearchClient'); const { broadcastTableUpdate, broadcastTableRelationsUpdate } = require('../wsHub'); +// Вспомогательная функция для получения ключа шифрования +function getEncryptionKey() { + const fs = require('fs'); + const keyPath = '/app/ssl/keys/full_db_encryption.key'; + + if (!fs.existsSync(keyPath)) { + throw new Error('Encryption key file not found'); + } + + return fs.readFileSync(keyPath, 'utf8').trim(); +} + router.use((req, res, next) => { console.log('Tables router received:', req.method, req.originalUrl); next(); @@ -28,17 +40,12 @@ router.use((req, res, next) => { router.get('/', async (req, res, next) => { try { // Получаем ключ шифрования - const fs = require('fs'); - const path = require('path'); - let encryptionKey = 'default-key'; - + let encryptionKey; try { - const keyPath = '/app/ssl/keys/full_db_encryption.key'; - if (fs.existsSync(keyPath)) { - encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); - } + encryptionKey = getEncryptionKey(); } catch (keyError) { console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); } const result = await db.getQuery()('SELECT id, created_at, updated_at, is_rag_source_id, decrypt_text(name_encrypted, $1) as name, decrypt_text(description_encrypted, $1) as description FROM user_tables ORDER BY id', [encryptionKey]); @@ -54,17 +61,12 @@ router.post('/', async (req, res, next) => { const { name, description, isRagSourceId } = req.body; // Получаем ключ шифрования - const fs = require('fs'); - const path = require('path'); - let encryptionKey = 'default-key'; - + let encryptionKey; try { - const keyPath = '/app/ssl/keys/full_db_encryption.key'; - if (fs.existsSync(keyPath)) { - encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); - } + encryptionKey = getEncryptionKey(); } catch (keyError) { console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); } const result = await db.getQuery()( @@ -81,17 +83,12 @@ router.post('/', async (req, res, next) => { router.get('/rag-sources', async (req, res, next) => { try { // Получаем ключ шифрования - const fs = require('fs'); - const path = require('path'); - let encryptionKey = 'default-key'; - + let encryptionKey; try { - const keyPath = '/app/ssl/keys/full_db_encryption.key'; - if (fs.existsSync(keyPath)) { - encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); - } + encryptionKey = getEncryptionKey(); } catch (keyError) { console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); } const result = await db.getQuery()( @@ -111,17 +108,12 @@ router.get('/:id', async (req, res, next) => { try { const tableId = req.params.id; // Получаем ключ шифрования - const fs = require('fs'); - const path = require('path'); - let encryptionKey = 'default-key'; - + let encryptionKey; try { - const keyPath = '/app/ssl/keys/full_db_encryption.key'; - if (fs.existsSync(keyPath)) { - encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); - } + encryptionKey = getEncryptionKey(); } catch (keyError) { console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); } // Выполняем все 4 запроса параллельно для ускорения @@ -164,13 +156,27 @@ function generatePlaceholder(name, existingPlaceholders = []) { return ''; }).join(''); translit = translit.replace(/_+/g, '_').replace(/^_+|_+$/g, ''); + + // Если translit пустой, используем fallback + if (!translit) { + translit = 'column'; + } + let base = translit; let candidate = base; let i = 1; + + // Генерируем уникальный плейсхолдер while (existingPlaceholders.includes(candidate)) { candidate = `${base}_${i}`; i++; + // Защита от бесконечного цикла + if (i > 1000) { + candidate = `${base}_${Date.now()}`; + break; + } } + return candidate; } @@ -190,7 +196,13 @@ router.post('/:id/columns', async (req, res, next) => { // Получаем ключ шифрования const fs = require('fs'); const path = require('path'); - let encryptionKey = 'default-key'; + let encryptionKey; + try { + encryptionKey = getEncryptionKey(); + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); + } try { const keyPath = '/app/ssl/keys/full_db_encryption.key'; @@ -201,8 +213,8 @@ router.post('/:id/columns', async (req, res, next) => { console.error('Error reading encryption key:', keyError); } - // Получаем уже существующие плейсхолдеры в таблице - const existing = (await db.getQuery()('SELECT placeholder FROM user_columns WHERE table_id = $1', [tableId])).rows; + // Получаем уже существующие плейсхолдеры во всей базе данных + const existing = (await db.getQuery()('SELECT placeholder FROM user_columns WHERE placeholder IS NOT NULL', [])).rows; const existingPlaceholders = existing.map(c => c.placeholder).filter(Boolean); const placeholder = generatePlaceholder(name, existingPlaceholders); const result = await db.getQuery()( @@ -228,7 +240,13 @@ router.post('/:id/rows', async (req, res, next) => { // Получаем ключ шифрования const fs = require('fs'); const path = require('path'); - let encryptionKey = 'default-key'; + let encryptionKey; + try { + encryptionKey = getEncryptionKey(); + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); + } try { const keyPath = '/app/ssl/keys/full_db_encryption.key'; @@ -262,7 +280,13 @@ router.get('/:id/rows', async (req, res, next) => { // Получаем ключ шифрования const fs = require('fs'); const path = require('path'); - let encryptionKey = 'default-key'; + let encryptionKey; + try { + encryptionKey = getEncryptionKey(); + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); + } try { const keyPath = '/app/ssl/keys/full_db_encryption.key'; @@ -347,7 +371,13 @@ router.post('/cell', async (req, res, next) => { // Получаем ключ шифрования const fs = require('fs'); const path = require('path'); - let encryptionKey = 'default-key'; + let encryptionKey; + try { + encryptionKey = getEncryptionKey(); + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); + } try { const keyPath = '/app/ssl/keys/full_db_encryption.key'; @@ -365,21 +395,11 @@ router.post('/cell', async (req, res, next) => { [row_id, column_id, value, encryptionKey] ); - // Получаем table_id и проверяем, является ли это таблицей тегов + // Получаем table_id const table = (await db.getQuery()('SELECT table_id FROM user_rows WHERE id = $1', [row_id])).rows[0]; if (table) { const tableId = table.table_id; - // Проверяем, является ли это таблицей "Теги клиентов" - const tableName = (await db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name FROM user_tables WHERE id = $1', [tableId, encryptionKey])).rows[0]; - console.log('🔄 [Tables] Проверяем таблицу:', { tableId, tableName: tableName?.name }); - if (tableName && tableName.name === 'Теги клиентов') { - // Отправляем WebSocket уведомление об обновлении тегов - console.log('🔄 [Tables] Обновление ячейки в таблице тегов, отправляем уведомление'); - const { broadcastTagsUpdate } = require('../wsHub'); - broadcastTagsUpdate(); - } - // Получаем всю строку для upsert const rowData = (await db.getQuery()('SELECT r.id as row_id, decrypt_text(c.value_encrypted, $2) as text, decrypt_text(c2.value_encrypted, $2) 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.id = $1', [row_id, encryptionKey])).rows[0]; if (rowData) { @@ -408,35 +428,26 @@ router.delete('/row/:rowId', async (req, res, next) => { // Получаем table_id const table = (await db.getQuery()('SELECT table_id FROM user_rows WHERE id = $1', [rowId])).rows[0]; - // Проверяем, является ли это таблицей тегов перед удалением - let isTagsTable = false; - if (table) { - const fs = require('fs'); - const path = require('path'); - let encryptionKey = 'default-key'; - - try { - const keyPath = '/app/ssl/keys/full_db_encryption.key'; - if (fs.existsSync(keyPath)) { - encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); - } - } catch (keyError) { - console.error('Error reading encryption key:', keyError); - } - - const tableName = (await db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name FROM user_tables WHERE id = $1', [table.table_id, encryptionKey])).rows[0]; - isTagsTable = tableName && tableName.name === 'Теги клиентов'; + if (!table) { + return res.status(404).json({ error: 'Row not found' }); } + const tableId = table.table_id; + + // Удаляем строку await db.getQuery()('DELETE FROM user_rows WHERE id = $1', [rowId]); - if (table) { - const tableId = table.table_id; - // Получаем все строки для rebuild - // Получаем ключ шифрования + // Получаем все строки для rebuild + // Получаем ключ шифрования const fs = require('fs'); const path = require('path'); - let encryptionKey = 'default-key'; + let encryptionKey; + try { + encryptionKey = getEncryptionKey(); + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); + } try { const keyPath = '/app/ssl/keys/full_db_encryption.key'; @@ -448,18 +459,10 @@ router.delete('/row/:rowId', async (req, res, next) => { } const rows = (await db.getQuery()('SELECT r.id as row_id, decrypt_text(c.value_encrypted, $2) as text, decrypt_text(c2.value_encrypted, $2) 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, encryptionKey])).rows; - const rebuildRows = rows.filter(r => r.row_id && r.text).map(r => ({ row_id: r.row_id, text: r.text, metadata: { answer: r.answer } })); - console.log('[DEBUG][rebuildRows]', rebuildRows); - if (rebuildRows.length > 0) { - await vectorSearchClient.rebuild(tableId, rebuildRows); - } - } - - // Отправляем WebSocket уведомление, если это была таблица тегов - if (isTagsTable) { - console.log('🔄 [Tables] Обновление строки в таблице тегов, отправляем уведомление'); - const { broadcastTagsUpdate } = require('../wsHub'); - broadcastTagsUpdate(); + const rebuildRows = rows.filter(r => r.row_id && r.text).map(r => ({ row_id: r.row_id, text: r.text, metadata: { answer: r.answer } })); + console.log('[DEBUG][rebuildRows]', rebuildRows); + if (rebuildRows.length > 0) { + await vectorSearchClient.rebuild(tableId, rebuildRows); } // Отправляем WebSocket уведомление об обновлении таблицы @@ -468,6 +471,7 @@ router.delete('/row/:rowId', async (req, res, next) => { res.json({ success: true }); } catch (err) { + console.error('[DELETE /row/:rowId] Error:', err); next(err); } }); @@ -476,9 +480,29 @@ router.delete('/row/:rowId', async (req, res, next) => { router.delete('/column/:columnId', async (req, res, next) => { try { const columnId = req.params.columnId; + + // Получаем информацию о столбце перед удалением + const columnInfo = (await db.getQuery()('SELECT table_id FROM user_columns WHERE id = $1', [columnId])).rows[0]; + if (!columnInfo) { + return res.status(404).json({ error: 'Column not found' }); + } + + // Удаляем все связанные данные в правильном порядке + // 1. Удаляем relations, связанные с этим столбцом + await db.getQuery()('DELETE FROM user_table_relations WHERE column_id = $1', [columnId]); + + // 2. Удаляем все значения ячеек для этого столбца + await db.getQuery()('DELETE FROM user_cell_values WHERE column_id = $1', [columnId]); + + // 3. Удаляем сам столбец await db.getQuery()('DELETE FROM user_columns WHERE id = $1', [columnId]); + + // Отправляем WebSocket уведомление об обновлении таблицы + broadcastTableUpdate(columnInfo.table_id); + res.json({ success: true }); } catch (err) { + console.error('[DELETE /column/:columnId] Error:', err); next(err); } }); @@ -492,7 +516,13 @@ router.patch('/column/:columnId', async (req, res, next) => { // Получаем ключ шифрования const fs = require('fs'); const path = require('path'); - let encryptionKey = 'default-key'; + let encryptionKey; + try { + encryptionKey = getEncryptionKey(); + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); + } try { const keyPath = '/app/ssl/keys/full_db_encryption.key'; @@ -616,7 +646,13 @@ router.post('/:id/rebuild-index', requireAuth, async (req, res, next) => { // Получаем ключ шифрования const fs = require('fs'); - let encryptionKey = 'default-key'; + let encryptionKey; + try { + encryptionKey = getEncryptionKey(); + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); + } try { const keyPath = '/app/ssl/keys/full_db_encryption.key'; @@ -665,37 +701,38 @@ router.post('/:id/rebuild-index', requireAuth, async (req, res, next) => { // DELETE: удалить таблицу и каскадно все связанные строки/столбцы/ячейки (доступно всем) router.delete('/:id', requireAuth, async (req, res, next) => { - const dbModule = require('../db'); try { - // Логируем строку подключения и pool.options - console.log('[DIAG][DELETE] pool.options:', dbModule.pool.options); - console.log('[DIAG][DELETE] process.env.DATABASE_URL:', process.env.DATABASE_URL); - console.log('[DIAG][DELETE] process.env.DB_HOST:', process.env.DB_HOST); - console.log('[DIAG][DELETE] process.env.DB_NAME:', process.env.DB_NAME); - console.log('=== [DIAG] Попытка удаления таблицы ==='); - console.log('Сессия пользователя:', req.session); if (!req.session.isAdmin) { - console.log('[DIAG] Нет прав администратора'); return res.status(403).json({ error: 'Удаление доступно только администраторам' }); } + const tableId = Number(req.params.id); - console.log('[DIAG] id из запроса:', req.params.id, 'Преобразованный id:', tableId, 'typeof:', typeof tableId); - + // Проверяем наличие таблицы перед удалением - const checkBefore = await db.getQuery()('SELECT * FROM user_tables WHERE id = $1', [tableId]); - console.log('[DIAG] Таблица перед удалением:', checkBefore.rows); - - // Пытаемся удалить + const tableExists = (await db.getQuery()('SELECT id FROM user_tables WHERE id = $1', [tableId])).rows[0]; + if (!tableExists) { + return res.status(404).json({ error: 'Table not found' }); + } + + // Удаляем все связанные данные в правильном порядке + // 1. Удаляем все relations, где эта таблица является источником или целью + await db.getQuery()('DELETE FROM user_table_relations WHERE from_row_id IN (SELECT id FROM user_rows WHERE table_id = $1) OR to_table_id = $1', [tableId]); + + // 2. Удаляем все значения ячеек для строк этой таблицы + await db.getQuery()('DELETE FROM user_cell_values WHERE row_id IN (SELECT id FROM user_rows WHERE table_id = $1)', [tableId]); + + // 3. Удаляем все строки таблицы + await db.getQuery()('DELETE FROM user_rows WHERE table_id = $1', [tableId]); + + // 4. Удаляем все столбцы таблицы + await db.getQuery()('DELETE FROM user_columns WHERE table_id = $1', [tableId]); + + // 5. Удаляем саму таблицу const result = await db.getQuery()('DELETE FROM user_tables WHERE id = $1 RETURNING *', [tableId]); - console.log('[DIAG] Результат удаления (rowCount):', result.rowCount, 'rows:', result.rows); - - // Проверяем наличие таблицы после удаления - const checkAfter = await db.getQuery()('SELECT * FROM user_tables WHERE id = $1', [tableId]); - console.log('[DIAG] Таблица после удаления:', checkAfter.rows); - + res.json({ success: true, deleted: result.rowCount }); } catch (err) { - console.error('[DIAG] Ошибка при удалении таблицы:', err); + console.error('[DELETE /:id] Error:', err); next(err); } }); @@ -718,14 +755,51 @@ router.get('/:tableId/row/:rowId/relations', async (req, res, next) => { 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]); + const { column_id, to_table_id, to_row_ids } = req.body; + + // Если передается массив to_row_ids - это массовое обновление + if (Array.isArray(to_row_ids)) { + // Удаляем старые связи для этого столбца + await db.getQuery()('DELETE FROM user_table_relations WHERE from_row_id = $1 AND column_id = $2', [rowId, column_id]); + + // Добавляем новые связи + if (to_row_ids.length > 0) { + const values = to_row_ids.map((to_row_id, index) => + `($1, $2, $3, $${index + 4})` + ).join(', '); + + const params = [rowId, column_id, to_table_id, ...to_row_ids]; + const result = await db.getQuery()( + `INSERT INTO user_table_relations (from_row_id, column_id, to_table_id, to_row_id) + VALUES ${values} RETURNING *`, + params + ); + + // Отправляем WebSocket уведомление + const { broadcastTagsUpdate } = require('../wsHub'); + broadcastTagsUpdate(null, rowId); + + res.json(result.rows); + } else { + res.json([]); + } + } else { + // Одиночная связь + const { 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] + ); + + // Отправляем WebSocket уведомление + const { broadcastTagsUpdate } = require('../wsHub'); + broadcastTagsUpdate(null, rowId); + + res.json(result.rows[0]); + } } catch (err) { + console.error('[POST /:tableId/row/:rowId/relations] Error:', err); next(err); } }); @@ -742,55 +816,8 @@ router.delete('/:tableId/row/:rowId/relations/:relationId', async (req, res, nex }); // --- Массовое обновление связей для 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 должен быть массивом' }); - - // Получаем ключ шифрования - const fs = require('fs'); - const path = require('path'); - let encryptionKey = 'default-key'; - - try { - const keyPath = '/app/ssl/keys/full_db_encryption.key'; - if (fs.existsSync(keyPath)) { - encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); - } - } catch (keyError) { - console.error('Error reading encryption key:', keyError); - } - - // Проверяем, является ли это обновлением тегов (проверяем связанную таблицу) - const relatedTableName = (await db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name FROM user_tables WHERE id = $1', [to_table_id, encryptionKey])).rows[0]; - console.log('🔄 [Tables] Multirelations: проверяем связанную таблицу:', { to_table_id, tableName: relatedTableName?.name }); - - if (relatedTableName && relatedTableName.name === 'Теги клиентов') { - console.log('🔄 [Tables] Multirelations: обновление тегов, отправляем уведомление'); - const { broadcastTagsUpdate } = require('../wsHub'); - broadcastTagsUpdate(); - } - - // Удаляем старые связи для этой строки/столбца - 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] - ); - } - - // Отправляем WebSocket уведомление об обновлении связей - broadcastTableRelationsUpdate(tableId, rowId); - - res.json({ success: true }); - } catch (err) { - next(err); - } -}); +// ПРИМЕЧАНИЕ: Для работы с тегами используйте /api/tags/user/:rowId/multirelations +// Этот endpoint удален для избежания дублирования // Получить плейсхолдеры для всех столбцов таблицы router.get('/:id/placeholders', async (req, res, next) => { @@ -799,7 +826,13 @@ router.get('/:id/placeholders', async (req, res, next) => { // Получаем ключ шифрования const fs = require('fs'); const path = require('path'); - let encryptionKey = 'default-key'; + let encryptionKey; + try { + encryptionKey = getEncryptionKey(); + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + return res.status(500).json({ error: 'Database encryption error' }); + } try { const keyPath = '/app/ssl/keys/full_db_encryption.key'; diff --git a/backend/routes/tags.js b/backend/routes/tags.js new file mode 100644 index 0000000..dc79f22 --- /dev/null +++ b/backend/routes/tags.js @@ -0,0 +1,134 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/HB3-ACCELERATOR + */ + +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const { requireAuth } = require('../middleware/auth'); +const { broadcastTagsUpdate } = require('../wsHub'); + +console.log('[tags.js] ROUTER LOADED'); + +router.use((req, res, next) => { + console.log('[tags.js] ROUTER REQUEST:', req.method, req.originalUrl); + next(); +}); + +// PATCH /api/tags/user/:userId — установить теги пользователю +router.patch('/user/:userId', async (req, res) => { + const userId = Number(req.params.userId); + 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] + ); + } + + // Отправляем WebSocket уведомление об обновлении тегов + broadcastTagsUpdate(null, userId); + + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// GET /api/tags/user/:userId — получить все теги пользователя +router.get('/user/:userId', async (req, res) => { + const userId = Number(req.params.userId); + 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/tags/user/:userId/tag/:tagId — удалить тег у пользователя +router.delete('/user/:userId/tag/:tagId', async (req, res) => { + const userId = Number(req.params.userId); + 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] + ); + + // Отправляем WebSocket уведомление об обновлении тегов + broadcastTagsUpdate(null, userId); + + res.json({ success: true }); + } catch (e) { + res.status(500).json({ error: e.message }); + } +}); + +// POST /api/tags/user/:rowId/multirelations — массовое обновление тегов через multirelations +router.post('/user/:rowId/multirelations', async (req, res) => { + const rowId = Number(req.params.rowId); + 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 должен быть массивом' }); + + // Получаем ключ шифрования + const fs = require('fs'); + const path = require('path'); + let encryptionKey = 'default-key'; + + try { + const keyPath = '/app/ssl/keys/full_db_encryption.key'; + if (fs.existsSync(keyPath)) { + encryptionKey = fs.readFileSync(keyPath, 'utf8').trim(); + } + } catch (keyError) { + console.error('Error reading encryption key:', keyError); + } + + // Проверяем, является ли это обновлением тегов (проверяем связанную таблицу) + const relatedTableName = (await db.getQuery()('SELECT decrypt_text(name_encrypted, $2) as name FROM user_tables WHERE id = $1', [to_table_id, encryptionKey])).rows[0]; + console.log('🔄 [Tags] Multirelations: проверяем связанную таблицу:', { to_table_id, tableName: relatedTableName?.name }); + + if (relatedTableName && relatedTableName.name === 'Теги клиентов') { + console.log('🔄 [Tags] Multirelations: обновление тегов для строки:', rowId); + + // Удаляем старые связи для этой строки/столбца + 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] + ); + } + + // Отправляем WebSocket уведомление об обновлении тегов + broadcastTagsUpdate(null, rowId); + + res.json({ success: true }); + } else { + res.status(400).json({ error: 'Этот endpoint предназначен только для работы с тегами' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/routes/users.js b/backend/routes/users.js index 78ed511..2a99d0b 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -561,67 +561,11 @@ 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] - ); - } - - // Отправляем WebSocket уведомление об обновлении тегов - const { broadcastTagsUpdate } = require('../wsHub'); - broadcastTagsUpdate(); - - 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] - ); - - // Отправляем WebSocket уведомление об обновлении тегов - const { broadcastTagsUpdate } = require('../wsHub'); - broadcastTagsUpdate(); - - res.json({ success: true }); - } catch (e) { - res.status(500).json({ error: e.message }); - } -}); +// --- Работа с тегами перенесена в /api/tags --- +// Используйте следующие endpoints: +// PATCH /api/tags/user/:id — установить теги пользователю +// GET /api/tags/user/:id — получить теги пользователя +// DELETE /api/tags/user/:id/tag/:tagId — удалить тег у пользователя +// POST /api/tags/user/:id/multirelations — массовое обновление тегов module.exports = router; diff --git a/backend/wsHub.js b/backend/wsHub.js index dd6965b..6c8ff65 100644 --- a/backend/wsHub.js +++ b/backend/wsHub.js @@ -20,6 +20,10 @@ const wsClients = new Map(); // userId -> Set of WebSocket connections const tagsChangeCache = new Map(); const TAGS_CACHE_TTL = 5000; // 5 секунд +// Дебаунс для broadcastTagsUpdate +let tagsUpdateTimeout = null; +const TAGS_UPDATE_DEBOUNCE = 100; // 100ms + function initWSS(server) { wss = new WebSocket.Server({ server, path: '/ws' }); @@ -234,20 +238,32 @@ function broadcastTableRelationsUpdate(tableId, rowId, targetUserId = null) { } } -function broadcastTagsUpdate(targetUserId = null) { - console.log('🔔 [WebSocket] Отправляем уведомление об обновлении тегов'); - const message = JSON.stringify({ - type: 'tags-updated', - timestamp: Date.now() - }); +function broadcastTagsUpdate(targetUserId = null, rowId = null) { + // Дебаунс: отменяем предыдущий таймаут + if (tagsUpdateTimeout) { + clearTimeout(tagsUpdateTimeout); + } - // Отправляем всем подключенным клиентам - wss.clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - console.log('🔔 [WebSocket] Отправляем tags-updated клиенту'); - client.send(message); - } - }); + // Устанавливаем новый таймаут + tagsUpdateTimeout = setTimeout(() => { + console.log('🔔 [WebSocket] Отправляем уведомление об обновлении тегов', rowId ? `для строки ${rowId}` : ''); + const message = JSON.stringify({ + type: 'tags-updated', + timestamp: Date.now(), + rowId: rowId // Добавляем информацию о конкретной строке + }); + + let sentCount = 0; + // Отправляем всем подключенным клиентам + wss.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + sentCount++; + } + }); + + console.log(`🔔 [WebSocket] Отправлено tags-updated ${sentCount} клиентам`); + }, TAGS_UPDATE_DEBOUNCE); } function getConnectedUsers() { diff --git a/frontend/src/components/tables/TableCell.vue b/frontend/src/components/tables/TableCell.vue index 24149f6..93e06d4 100644 --- a/frontend/src/components/tables/TableCell.vue +++ b/frontend/src/components/tables/TableCell.vue @@ -526,7 +526,7 @@ async function loadMultiRelationOptions() { // Дебаунсинг для loadMultiRelationValues let loadMultiRelationValuesTimer = null; -const LOAD_DEBOUNCE_DELAY = 100; // 100ms +const LOAD_DEBOUNCE_DELAY = 50; // 50ms (уменьшено для ускорения) async function loadMultiRelationValues() { // Проверяем, не загружены ли уже данные @@ -625,13 +625,20 @@ async function saveMultiRelation() { to_row_ids: editMultiRelationValues.value }; console.log('[saveMultiRelation] POST payload:', payload); - const response = await fetch(`/api/tables/${props.column.table_id}/row/${props.rowId}/multirelations`, { + console.log('[TableCell] Отправляем запрос на обновление relations для строки:', props.rowId); + console.log('[TableCell] Данные запроса:', payload); + const response = await fetch(`/api/tables/${props.column.table_id}/row/${props.rowId}/relations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const result = await response.json().catch(() => ({})); - console.log('[saveMultiRelation] API response status:', response.status, 'result:', result); + console.log('[TableCell] Ответ сервера для строки:', props.rowId, 'статус:', response.status, 'результат:', result); + if (response.ok) { + console.log('[TableCell] Успешно сохранены теги для строки:', props.rowId); + } else { + console.error('[TableCell] Ошибка сохранения тегов для строки:', props.rowId, 'статус:', response.status); + } editing.value = false; await loadMultiRelationValues(); console.log('[saveMultiRelation] emitting update with:', editMultiRelationValues.value); @@ -682,6 +689,9 @@ async function addTag() { ]); console.log('[addTag] Тег добавлен в выбранные:', editMultiRelationValues.value); + + // Сохраняем изменения, чтобы отправить WebSocket уведомление + await saveMultiRelation(); } catch (e) { console.error('[addTag] Ошибка при добавлении тега:', e); } @@ -707,6 +717,9 @@ async function deleteTag(tagId) { await loadMultiRelationOptions(); console.log('[deleteTag] Тег удален:', tagId); + + // Сохраняем изменения, чтобы отправить WebSocket уведомление + await saveMultiRelation(); } catch (e) { console.error('[deleteTag] Ошибка при удалении тега:', e); } diff --git a/frontend/src/components/tables/UserTableView.vue b/frontend/src/components/tables/UserTableView.vue index 6eb9512..9ac98fa 100644 --- a/frontend/src/components/tables/UserTableView.vue +++ b/frontend/src/components/tables/UserTableView.vue @@ -90,13 +90,19 @@ :resizable="false" >