ваше сообщение коммита

This commit is contained in:
2025-07-11 16:45:09 +03:00
parent e0ec889863
commit 584ff401ad
42 changed files with 1945 additions and 1004 deletions

View File

@@ -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);

View File

@@ -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)
);

View File

@@ -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;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_blocked BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS blocked_at timestamp;

View File

@@ -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);

View File

@@ -0,0 +1,3 @@
-- Миграция: добавление поля placeholder в user_columns
ALTER TABLE user_columns ADD COLUMN IF NOT EXISTS placeholder VARCHAR(255) UNIQUE;

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;

View File

@@ -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()(

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)

View File

@@ -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
);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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'
});

View File

@@ -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 };

View File

@@ -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));