/** * 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/VC-HB3-Accelerator */ const express = require('express'); const router = express.Router(); const db = require('../db'); const fs = require('fs'); const path = require('path'); const multer = require('multer'); const vectorSearchClient = require('../services/vectorSearchClient'); const FIELDS_TO_EXCLUDE = ['image', 'tags']; // Проверка и создание общей таблицы для всех админов async function ensureAdminPagesTable(fields) { fields = fields.filter(f => !FIELDS_TO_EXCLUDE.includes(f)); const tableName = `admin_pages_simple`; // Получаем ключ шифрования // Получаем ключ шифрования через унифицированную утилиту const encryptionUtils = require('../utils/encryptionUtils'); const encryptionKey = encryptionUtils.getEncryptionKey(); // Проверяем, есть ли таблица const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) { // Формируем SQL для создания таблицы с зашифрованными полями let columns = [ 'id SERIAL PRIMARY KEY', 'author_address_encrypted TEXT NOT NULL', // Зашифрованный адрес автора 'created_at TIMESTAMP DEFAULT NOW()', 'updated_at TIMESTAMP DEFAULT NOW()' ]; for (const field of fields) { columns.push(`"${field}_encrypted" TEXT`); } const sql = `CREATE TABLE ${tableName} (${columns.join(', ')})`; await db.getQuery()(sql); } else { // Проверяем, есть ли все нужные столбцы, и добавляем недостающие const colRes = await db.getQuery()( `SELECT column_name FROM information_schema.columns WHERE table_name = $1`, [tableName] ); const existingCols = colRes.rows.map(r => r.column_name); // Добавляем поле author_address_encrypted если его нет if (!existingCols.includes('author_address_encrypted')) { await db.getQuery()( `ALTER TABLE ${tableName} ADD COLUMN author_address_encrypted TEXT` ); } for (const field of fields) { const encryptedField = `${field}_encrypted`; if (!existingCols.includes(encryptedField)) { await db.getQuery()( `ALTER TABLE ${tableName} ADD COLUMN "${encryptedField}" TEXT` ); } } } return { tableName, encryptionKey }; } // Конфигурация загрузки файлов для юридических документов // Храним файлы там, откуда их раздаёт express.static('/uploads', path.join(__dirname, 'uploads')) const uploadsRoot = path.join(__dirname, '..', 'uploads'); const legalDir = path.join(uploadsRoot, 'legal'); if (!fs.existsSync(legalDir)) { try { fs.mkdirSync(legalDir, { recursive: true }); } catch (e) {} } const storage = multer.diskStorage({ destination: function (req, file, cb) { cb(null, legalDir); }, filename: function (req, file, cb) { const safeName = Date.now() + '-' + (file.originalname || 'file'); cb(null, safeName); } }); const upload = multer({ storage }); function stripHtml(html) { if (!html) return ''; return String(html).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); } // Создать страницу (только для админа) router.post('/', upload.single('file'), async (req, res) => { console.log('[pages] POST /: Начало обработки запроса на создание страницы'); try { if (!req.session || !req.session.authenticated) { console.log('[pages] POST /: Ошибка аутентификации - сессия не найдена'); return res.status(401).json({ error: 'Требуется аутентификация' }); } if (!req.session.address) { console.log('[pages] POST /: Ошибка - адрес кошелька не найден'); return res.status(403).json({ error: 'Требуется подключение кошелька' }); } console.log('[pages] POST /: Проверка прав доступа для адреса:', req.session.address); // Проверяем роль админа через токены в кошельке const authService = require('../services/auth-service'); let userAccessLevel; try { userAccessLevel = await authService.getUserAccessLevel(req.session.address); } catch (authError) { console.error('[pages] POST /: Ошибка при проверке прав доступа:', authError); if (authError.message && authError.message.includes('timeout exceeded')) { return res.status(503).json({ error: 'Ошибка подключения к базе данных. Попробуйте позже.' }); } throw authError; } if (!userAccessLevel.hasAccess) { console.log('[pages] POST /: Доступ запрещен - недостаточно прав'); return res.status(403).json({ error: 'Only admin can create pages' }); } console.log('[pages] POST /: Права доступа подтверждены, уровень:', userAccessLevel.level); const authorAddress = req.session.address; const tableName = `admin_pages_simple`; // Собираем данные страницы const bodyRaw = req.body || {}; // Обрабатываем required_permission: если это пустая строка или 'null', устанавливаем null let requiredPermission = null; if (bodyRaw.required_permission) { const perm = String(bodyRaw.required_permission).trim(); requiredPermission = (perm && perm !== 'null' && perm !== '') ? perm : null; } // Обрабатываем JSON поля (seo, settings) - могут прийти как строка из FormData let seoValue = null; if (bodyRaw.seo) { if (typeof bodyRaw.seo === 'string') { try { seoValue = JSON.parse(bodyRaw.seo); } catch (e) { seoValue = bodyRaw.seo.trim() ? bodyRaw.seo : null; } } else if (typeof bodyRaw.seo === 'object') { seoValue = bodyRaw.seo; } } let settingsValue = null; if (bodyRaw.settings) { if (typeof bodyRaw.settings === 'string') { try { settingsValue = JSON.parse(bodyRaw.settings); } catch (e) { settingsValue = bodyRaw.settings.trim() ? bodyRaw.settings : null; } } else if (typeof bodyRaw.settings === 'object') { settingsValue = bodyRaw.settings; } } const pageData = { title: bodyRaw.title || '', summary: bodyRaw.summary || '', content: bodyRaw.content || '', seo: seoValue, status: bodyRaw.status || 'draft', settings: settingsValue, visibility: bodyRaw.visibility || 'public', required_permission: requiredPermission, format: bodyRaw.format || (req.file ? (req.file.mimetype?.startsWith('image/') ? 'image' : 'pdf') : 'html'), mime_type: req.file ? (req.file.mimetype || null) : (bodyRaw.mime_type || (bodyRaw.format === 'html' ? 'text/html' : null)), storage_type: req.file ? 'file' : (bodyRaw.storage_type || 'embedded'), file_path: req.file ? path.join('/uploads', 'legal', path.basename(req.file.path)) : (bodyRaw.file_path || null), size_bytes: req.file ? req.file.size : (bodyRaw.size_bytes || null), checksum: bodyRaw.checksum || null, // Нормализуем категорию: приводим к нижнему регистру для консистентности category: (bodyRaw.category && String(bodyRaw.category).trim()) ? String(bodyRaw.category).trim().toLowerCase() : null, // Обрабатываем category_id: может быть null или числом category_id: (bodyRaw.category_id && bodyRaw.category_id !== 'null' && bodyRaw.category_id !== '') ? (() => { const parsed = parseInt(bodyRaw.category_id); return isNaN(parsed) ? null : parsed; })() : null, // Обрабатываем parent_id: может быть null или числом parent_id: (bodyRaw.parent_id && bodyRaw.parent_id !== 'null' && bodyRaw.parent_id !== '') ? (() => { const parsed = parseInt(bodyRaw.parent_id); return isNaN(parsed) ? null : parsed; })() : null, // Обрабатываем order_index: должно быть числом order_index: (bodyRaw.order_index && bodyRaw.order_index !== 'null' && bodyRaw.order_index !== '') ? (() => { const parsed = parseInt(bodyRaw.order_index); return isNaN(parsed) ? 0 : parsed; })() : 0, nav_path: bodyRaw.nav_path || null, is_index_page: bodyRaw.is_index_page === true || bodyRaw.is_index_page === 'true' }; console.log('[pages] POST /: Создание страницы, данные:', { title: pageData.title, visibility: pageData.visibility, required_permission: pageData.required_permission, status: pageData.status, format: pageData.format }); // Формируем SQL для вставки данных (включаем все поля, даже null) // Фильтруем только undefined, null значения включаем (они допустимы в БД) const dataEntries = Object.entries(pageData).filter(([k, v]) => { // Исключаем только undefined, null и пустые строки для некоторых полей допустимы return v !== undefined; }); const colNames = ['author_address', ...dataEntries.map(([k]) => k)].join(', '); const values = [authorAddress, ...dataEntries.map(([, v]) => v)]; const placeholders = values.map((_, i) => `$${i + 1}`).join(', '); const sql = `INSERT INTO ${tableName} (${colNames}) VALUES (${placeholders}) RETURNING *`; console.log('[pages] POST /: SQL запрос:', sql.substring(0, 300) + '...'); console.log('[pages] POST /: Количество параметров:', values.length); console.log('[pages] POST /: Колонки:', colNames); console.log('[pages] POST /: Значения (первые 5):', values.slice(0, 5).map(v => v === null ? 'NULL' : (typeof v === 'string' ? v.substring(0, 50) : v))); // Проверяем, что ответ еще не был отправлен перед запросом к БД if (res.headersSent || res.destroyed) { console.error('[pages] POST /: Ответ уже отправлен перед запросом к БД'); return; } console.log('[pages] POST /: Выполнение SQL запроса к БД...'); let rows; try { const result = await db.getQuery()(sql, values); rows = result.rows; console.log('[pages] POST /: SQL запрос выполнен успешно, создана страница с ID:', rows[0]?.id); } catch (dbError) { console.error('[pages] POST /: Ошибка БД при выполнении SQL:', dbError); console.error('[pages] POST /: Код ошибки БД:', dbError.code); console.error('[pages] POST /: Сообщение БД:', dbError.message); console.error('[pages] POST /: Детали БД:', dbError.detail); throw dbError; } const created = rows[0]; // Проверяем еще раз перед отправкой ответа if (res.headersSent || res.destroyed) { console.error('[pages] POST /: Ответ уже отправлен после запроса к БД'); return; } // Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex) // Автоматическая индексация при создании отключена res.json(created); } catch (error) { console.error('[pages] Ошибка при создании страницы:', error); console.error('[pages] Стек ошибки:', error.stack); console.error('[pages] Код ошибки:', error.code); console.error('[pages] Сообщение ошибки:', error.message); // Если ответ уже отправлен, не пытаемся отправлять ошибку if (res.headersSent || res.destroyed) { console.error('[pages] POST /: Ответ уже отправлен в catch блоке'); return; } // Определяем статус код на основе типа ошибки let statusCode = 500; let errorMessage = 'Ошибка при создании страницы'; if (error.message && error.message.includes('timeout exceeded when trying to connect')) { statusCode = 503; // Service Unavailable errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.'; } else if (error.code === '23505') { // PostgreSQL unique violation statusCode = 409; // Conflict errorMessage = 'Страница с такими данными уже существует'; } else if (error.code === '23502') { // PostgreSQL not null violation statusCode = 400; // Bad Request errorMessage = 'Отсутствует обязательное поле: ' + (error.column || 'неизвестно'); } else if (error.code === '42703') { // PostgreSQL undefined column statusCode = 400; // Bad Request errorMessage = 'Неверное поле в запросе: ' + (error.message || 'неизвестно'); } else if (error.message) { errorMessage = error.message; } res.status(statusCode).json({ success: false, error: errorMessage, message: errorMessage }); } }); // Получить все страницы админов router.get('/', async (req, res) => { if (!req.session || !req.session.authenticated) { return res.status(401).json({ error: 'Требуется аутентификация' }); } if (!req.session.address) { return res.status(403).json({ error: 'Требуется подключение кошелька' }); } // Проверяем роль админа через токены в кошельке const authService = require('../services/auth-service'); const userAccessLevel = await authService.getUserAccessLevel(req.session.address); if (!userAccessLevel.hasAccess) { return res.status(403).json({ error: 'Only admin can view pages' }); } const tableName = `admin_pages_simple`; // Получаем ключ шифрования // Получаем ключ шифрования через унифицированную утилиту const encryptionUtils = require('../utils/encryptionUtils'); const encryptionKey = encryptionUtils.getEncryptionKey(); // Проверяем, есть ли таблица const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) return res.json([]); // Получаем все страницы всех админов const { rows } = await db.getQuery()(` SELECT * FROM ${tableName} ORDER BY created_at DESC `); res.json(rows); }); // ========== РОУТЫ ДЛЯ КАТЕГОРИЙ (должны быть ПЕРЕД параметрическими роутами типа /:id) ========== // Создать категорию router.post('/categories', async (req, res) => { if (!req.session || !req.session.authenticated) { return res.status(401).json({ error: 'Требуется аутентификация' }); } if (!req.session.address) { return res.status(403).json({ error: 'Требуется подключение кошелька' }); } const authService = require('../services/auth-service'); const userAccessLevel = await authService.getUserAccessLevel(req.session.address); if (!userAccessLevel.hasAccess) { return res.status(403).json({ error: 'Only admin can create categories' }); } try { const { name, display_name, description, order_index } = req.body; if (!name || !name.trim()) { return res.status(400).json({ error: 'Название категории обязательно' }); } const normalizedName = name.trim().toLowerCase(); const displayName = display_name || name.trim(); // Проверяем, не существует ли уже категория с таким названием const existsRes = await db.getQuery()( `SELECT id FROM document_categories WHERE name = $1`, [normalizedName] ); if (existsRes.rows.length > 0) { return res.status(409).json({ error: 'Категория с таким названием уже существует' }); } // Создаем категорию const { rows } = await db.getQuery()( `INSERT INTO document_categories (name, display_name, description, order_index, author_address) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [normalizedName, displayName, description || null, order_index || 0, req.session.address] ); console.log(`[pages] POST /categories: создана категория "${normalizedName}"`); res.json(rows[0]); } catch (error) { console.error('[pages] Ошибка создания категории:', error); // Если таблица не существует, возвращаем успех (для обратной совместимости) if (error.message.includes('does not exist') || error.message.includes('relation')) { console.warn('[pages] Таблица document_categories не существует, пропускаем создание'); res.json({ name: req.body.name.trim().toLowerCase(), display_name: req.body.name.trim() }); } else { res.status(500).json({ error: 'Ошибка создания категории: ' + error.message }); } } }); // Удалить категорию router.delete('/categories/:name', async (req, res) => { if (!req.session || !req.session.authenticated) { return res.status(401).json({ error: 'Требуется аутентификация' }); } if (!req.session.address) { return res.status(403).json({ error: 'Требуется подключение кошелька' }); } const authService = require('../services/auth-service'); const userAccessLevel = await authService.getUserAccessLevel(req.session.address); if (!userAccessLevel.hasAccess) { return res.status(403).json({ error: 'Only admin can delete categories' }); } try { const categoryName = decodeURIComponent(req.params.name).toLowerCase(); if (categoryName === 'uncategorized') { return res.status(400).json({ error: 'Нельзя удалить категорию "Без категории"' }); } // Удаляем категорию const { rows } = await db.getQuery()( `DELETE FROM document_categories WHERE name = $1 RETURNING *`, [categoryName] ); if (rows.length === 0) { return res.status(404).json({ error: 'Категория не найдена' }); } console.log(`[pages] DELETE /categories/:name: удалена категория "${categoryName}"`); res.json({ success: true, deleted: rows[0] }); } catch (error) { console.error('[pages] Ошибка удаления категории:', error); // Если таблица не существует, возвращаем успех (для обратной совместимости) if (error.message.includes('does not exist') || error.message.includes('relation')) { console.warn('[pages] Таблица document_categories не существует, пропускаем удаление'); res.json({ success: true }); } else { res.status(500).json({ error: 'Ошибка удаления категории: ' + error.message }); } } }); // Получить список всех категорий (для выпадающего списка) router.get('/categories', async (req, res) => { try { // Сначала пытаемся получить категории из таблицы document_categories try { const categoriesRes = await db.getQuery()( `SELECT name, display_name FROM document_categories ORDER BY order_index, created_at` ); if (categoriesRes.rows.length > 0) { const categories = categoriesRes.rows.map(row => row.name); return res.json(categories); } } catch (err) { console.warn('[pages] Таблица document_categories не существует, используем старый метод'); } // Fallback: получаем категории из документов const tableName = `admin_pages_simple`; const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) { return res.json([]); } // Получаем уникальные категории из опубликованных публичных страниц const { rows } = await db.getQuery()(` SELECT DISTINCT category FROM ${tableName} WHERE visibility = 'public' AND status = 'published' AND category IS NOT NULL AND category != '' ORDER BY category ASC `); const categories = rows.map(row => row.category).filter(Boolean); res.json(categories); } catch (error) { console.error('Ошибка получения категорий:', error); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // ========== КОНЕЦ РОУТОВ ДЛЯ КАТЕГОРИЙ ========== // Получить одну страницу по id (с проверкой прав доступа) router.get('/:id', async (req, res) => { try { if (!req.session || !req.session.authenticated) { return res.status(401).json({ error: 'Требуется аутентификация' }); } if (!req.session.address) { return res.status(403).json({ error: 'Требуется подключение кошелька' }); } const tableName = `admin_pages_simple`; const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' }); const { rows } = await db.getQuery()( `SELECT * FROM ${tableName} WHERE id = $1`, [req.params.id] ); if (!rows.length) return res.status(404).json({ error: 'Page not found' }); const page = rows[0]; // Проверяем доступ к странице в зависимости от её видимости const authService = require('../services/auth-service'); const userAccessLevel = await authService.getUserAccessLevel(req.session.address); const role = userAccessLevel.level; // 'user' | 'readonly' | 'editor' // Публичные страницы доступны всем if (page.visibility === 'public' && page.status === 'published') { return res.json(page); } // Внутренние страницы требуют проверки прав if (page.visibility === 'internal') { // Редактор видит все внутренние страницы (включая черновики) if (role === 'editor') { return res.json(page); } // Обычные пользователи видят только опубликованные внутренние страницы if (page.status !== 'published') { return res.status(403).json({ error: 'Доступ запрещен: страница не опубликована' }); } // Если у страницы указан required_permission, проверяем права пользователя if (page.required_permission) { const { PERMISSIONS, hasPermission } = require('../shared/permissions'); // Проверяем права доступа пользователя // VIEW_BASIC_DOCS доступно всем аутентифицированным пользователям (user, readonly, editor) // VIEW_LEGAL_DOCS требует readonly или editor // MANAGE_LEGAL_DOCS требует editor if (page.required_permission === PERMISSIONS.VIEW_LEGAL_DOCS && !hasPermission(role, PERMISSIONS.VIEW_LEGAL_DOCS)) { return res.status(403).json({ error: 'Доступ запрещен: требуются права читателя' }); } if (page.required_permission === PERMISSIONS.MANAGE_LEGAL_DOCS && !hasPermission(role, PERMISSIONS.MANAGE_LEGAL_DOCS)) { return res.status(403).json({ error: 'Доступ запрещен: требуются права редактора' }); } } // Если required_permission не указан или права проверены, возвращаем страницу return res.json(page); } // Для всех остальных случаев (например, draft публичных страниц) требуется роль редактора if (role !== 'editor') { return res.status(403).json({ error: 'Доступ запрещен: требуются права редактора' }); } res.json(page); } catch (error) { console.error('[pages] Ошибка получения страницы:', error); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Ручная переиндексация документа в vector-search (только для админа) router.post('/:id/reindex', async (req, res) => { if (!req.session || !req.session.authenticated) { return res.status(401).json({ error: 'Требуется аутентификация' }); } if (!req.session.address) { return res.status(403).json({ error: 'Требуется подключение кошелька' }); } // Проверяем роль админа через токены в кошельке const authService = require('../services/auth-service'); const userAccessLevel = await authService.getUserAccessLevel(req.session.address); if (!userAccessLevel.hasAccess) { return res.status(403).json({ error: 'Only admin can reindex pages' }); } const tableName = `admin_pages_simple`; const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' }); const { rows } = await db.getQuery()( `SELECT * FROM ${tableName} WHERE id = $1`, [req.params.id] ); if (!rows.length) return res.status(404).json({ error: 'Page not found' }); const page = rows[0]; if (page.format !== 'html') { return res.status(422).json({ error: 'Индексация поддерживается только для HTML' }); } const text = stripHtml(page.content || ''); if (!text) { return res.status(422).json({ error: 'Пустое содержимое для индексации' }); } try { const url = page.visibility === 'public' && page.status === 'published' ? `/public/page/${page.id}` : `/content/page/${page.id}`; // Удаляем старые чанки документа перед реиндексацией // Удаляем возможные чанки (doc_id_chunk_0, doc_id_chunk_1, ...) и сам документ (doc_id) const oldRowIds = [String(page.id)]; // Удаляем основной документ // Также удаляем возможные чанки (до 100 чанков на документ) for (let i = 0; i < 100; i++) { oldRowIds.push(`${page.id}_chunk_${i}`); } try { await vectorSearchClient.remove('legal_docs', oldRowIds); console.log(`[pages] Удалены старые чанки документа ${page.id} перед реиндексацией`); } catch (removeError) { console.warn(`[pages] Ошибка удаления старых чанков (продолжаем индексацию):`, removeError.message); // Продолжаем индексацию даже если удаление не удалось } // Используем Semantic Chunking для разбивки документа const semanticChunkingService = require('../services/semanticChunkingService'); const docLength = text.length; const useLLM = docLength <= 8000; const chunks = await semanticChunkingService.chunkDocument(text, { maxChunkSize: 1500, overlap: 200, useLLM }); // Индексируем каждый чанк отдельно const rowsToUpsert = chunks.map((chunk, index) => ({ row_id: `${page.id}_chunk_${index}`, text: chunk.text, metadata: { doc_id: page.id, chunk_index: index, section: chunk.metadata?.section || 'Документ', parent_doc_id: page.id, title: page.title, url: `${url}#chunk_${index}`, visibility: page.visibility, required_permission: page.required_permission, format: page.format, updated_at: page.updated_at || null, isComplete: chunk.metadata?.isComplete || false } })); if (chunks.length > 1) { console.log(`[pages] Документ ${page.id} разбит на ${chunks.length} чанков при реиндексации`); await vectorSearchClient.upsert('legal_docs', rowsToUpsert); } else { // Если чанк один, индексируем как раньше await vectorSearchClient.upsert('legal_docs', [{ row_id: page.id, text, metadata: { doc_id: page.id, title: page.title, url, visibility: page.visibility, required_permission: page.required_permission, format: page.format, updated_at: page.updated_at || null } }]); } res.json({ success: true, chunksCount: chunks.length }); } catch (e) { console.error('[pages] manual reindex error:', e.message); res.status(500).json({ error: 'Ошибка индексации' }); } }); // Редактировать страницу по id router.patch('/:id', upload.single('file'), async (req, res) => { console.log('[pages] PATCH /:id: Начало обработки запроса на обновление страницы ID:', req.params.id); try { if (!req.session || !req.session.authenticated) { console.log('[pages] PATCH /:id: Ошибка аутентификации - сессия не найдена'); return res.status(401).json({ error: 'Требуется аутентификация' }); } if (!req.session.address) { console.log('[pages] PATCH /:id: Ошибка - адрес кошелька не найден'); return res.status(403).json({ error: 'Требуется подключение кошелька' }); } console.log('[pages] PATCH /:id: Проверка прав доступа для адреса:', req.session.address); // Проверяем роль админа через токены в кошельке const authService = require('../services/auth-service'); let userAccessLevel; try { userAccessLevel = await authService.getUserAccessLevel(req.session.address); } catch (authError) { console.error('[pages] PATCH /:id: Ошибка при проверке прав доступа:', authError); if (authError.message && authError.message.includes('timeout exceeded')) { return res.status(503).json({ error: 'Ошибка подключения к базе данных. Попробуйте позже.' }); } throw authError; } if (!userAccessLevel.hasAccess) { console.log('[pages] PATCH /:id: Доступ запрещен - недостаточно прав'); return res.status(403).json({ error: 'Only admin can edit pages' }); } console.log('[pages] PATCH /:id: Права доступа подтверждены, уровень:', userAccessLevel.level); const tableName = `admin_pages_simple`; const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' }); const incoming = req.body || {}; const updateData = {}; console.log(`[pages] PATCH /:id (${req.params.id}): получены данные для обновления:`, JSON.stringify(incoming, null, 2)); console.log(`[pages] PATCH /:id (${req.params.id}): тип req.body:`, typeof req.body); console.log(`[pages] PATCH /:id (${req.params.id}): ключи в req.body:`, Object.keys(incoming)); // Обрабатываем required_permission: // Если visibility меняется на public, required_permission должен быть null // Если visibility = internal и нет required_permission, устанавливаем null if ('visibility' in incoming && incoming.visibility === 'public') { updateData.required_permission = null; } else if ('required_permission' in incoming) { if (incoming.required_permission) { const perm = String(incoming.required_permission).trim(); updateData.required_permission = (perm && perm !== 'null' && perm !== '') ? perm : null; } else { updateData.required_permission = null; } } for (const [k, v] of Object.entries(incoming)) { if (FIELDS_TO_EXCLUDE.includes(k)) continue; if (k === 'required_permission') continue; // Уже обработано выше // Нормализуем категорию: приводим к нижнему регистру для консистентности if (k === 'category') { updateData[k] = (v && String(v).trim()) ? String(v).trim().toLowerCase() : null; } // Обрабатываем category_id: может быть null или числом else if (k === 'category_id') { if (v === null || v === 'null' || v === '' || v === undefined) { updateData[k] = null; } else { const parsed = parseInt(v); updateData[k] = isNaN(parsed) ? null : parsed; } } // Обрабатываем parent_id: может быть null или числом else if (k === 'parent_id') { if (v === null || v === 'null' || v === '' || v === undefined) { updateData[k] = null; } else { const parsed = parseInt(v); updateData[k] = isNaN(parsed) ? null : parsed; } } // Обрабатываем order_index: должно быть числом else if (k === 'order_index') { if (v === null || v === 'null' || v === '' || v === undefined) { updateData[k] = 0; } else { const parsed = parseInt(v); updateData[k] = isNaN(parsed) ? 0 : parsed; } } // Обрабатываем is_index_page: должно быть boolean else if (k === 'is_index_page') { updateData[k] = v === true || v === 'true' || v === 1 || v === '1'; } // Обрабатываем JSON поля (seo, settings) - могут прийти как строка из FormData else if (k === 'seo' || k === 'settings') { if (typeof v === 'string') { try { // Если это строка JSON, пытаемся распарсить const parsed = JSON.parse(v); updateData[k] = parsed; } catch (e) { // Если не JSON, сохраняем как строку или null updateData[k] = v && v.trim() ? v : null; } } else if (typeof v === 'object' && v !== null) { // Если это уже объект, сериализуем в JSON updateData[k] = v; } else { updateData[k] = v || null; } } // Остальные поля else { updateData[k] = typeof v === 'object' && v !== null ? JSON.stringify(v) : v; } } console.log(`[pages] PATCH /:id (${req.params.id}): обработанные данные для обновления:`, updateData); if (req.file) { updateData.format = req.file.mimetype?.startsWith('image/') ? 'image' : 'pdf'; updateData.mime_type = req.file.mimetype || null; updateData.storage_type = 'file'; updateData.file_path = path.join('/uploads', 'legal', path.basename(req.file.path)); updateData.size_bytes = req.file.size; } const entries = Object.entries(updateData); if (!entries.length) return res.status(400).json({ error: 'No fields to update' }); const setClause = entries.map(([f], i) => `"${f}" = $${i + 1}`).join(', '); const values = entries.map(([, v]) => v); values.push(req.params.id); const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = NOW() WHERE id = $${entries.length + 1} RETURNING *`; console.log(`[pages] PATCH /:id (${req.params.id}): SQL запрос:`, sql); console.log(`[pages] PATCH /:id (${req.params.id}): значения:`, values); // Проверяем, что ответ еще не был отправлен перед запросом к БД if (res.headersSent || res.destroyed) { console.error('[pages] PATCH /:id: Ответ уже отправлен перед запросом к БД'); return; } console.log('[pages] PATCH /:id: Выполнение SQL запроса к БД...'); let rows; try { const result = await db.getQuery()(sql, values); rows = result.rows; } catch (dbError) { console.error('[pages] PATCH /:id: Ошибка БД при выполнении SQL:', dbError); console.error('[pages] PATCH /:id: Код ошибки БД:', dbError.code); console.error('[pages] PATCH /:id: Сообщение БД:', dbError.message); console.error('[pages] PATCH /:id: Детали БД:', dbError.detail); // Если ответ уже отправлен, не пытаемся отправлять ошибку if (res.headersSent || res.destroyed) { console.error('[pages] PATCH /:id: Ответ уже отправлен в catch блоке БД'); return; } // Определяем статус код на основе типа ошибки let statusCode = 500; let errorMessage = 'Ошибка при обновлении страницы'; if (dbError.message && dbError.message.includes('timeout exceeded when trying to connect')) { statusCode = 503; // Service Unavailable errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.'; } else if (dbError.code === '23505') { // PostgreSQL unique violation statusCode = 409; // Conflict errorMessage = 'Страница с такими данными уже существует'; } else if (dbError.code === '23502') { // PostgreSQL not null violation statusCode = 400; // Bad Request errorMessage = 'Отсутствует обязательное поле: ' + (dbError.column || 'неизвестно'); } else if (dbError.code === '42703') { // PostgreSQL undefined column statusCode = 400; // Bad Request errorMessage = 'Неверное поле в запросе: ' + (dbError.message || 'неизвестно'); } else if (dbError.message) { errorMessage = dbError.message; } return res.status(statusCode).json({ success: false, error: errorMessage, message: errorMessage }); } if (!rows.length) { console.error('[pages] PATCH /:id: Страница не найдена после обновления'); return res.status(404).json({ error: 'Page not found' }); } const updated = rows[0]; console.log(`[pages] PATCH /:id (${req.params.id}): страница успешно обновлена:`, { id: updated.id, title: updated.title, category: updated.category, parent_id: updated.parent_id, order_index: updated.order_index, is_index_page: updated.is_index_page, visibility: updated.visibility, required_permission: updated.required_permission }); // Проверяем еще раз перед отправкой ответа if (res.headersSent || res.destroyed) { console.error('[pages] PATCH /:id: Ответ уже отправлен после запроса к БД'); return; } // Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex) // Автоматическая индексация при обновлении отключена res.json(updated); } catch (error) { console.error('[pages] PATCH /:id: Ошибка при обновлении страницы:', error); console.error('[pages] PATCH /:id: Стек ошибки:', error.stack); console.error('[pages] PATCH /:id: Код ошибки:', error.code); console.error('[pages] PATCH /:id: Сообщение ошибки:', error.message); // Если ответ уже отправлен, не пытаемся отправлять ошибку if (res.headersSent || res.destroyed) { console.error('[pages] PATCH /:id: Ответ уже отправлен в catch блоке'); return; } // Определяем статус код на основе типа ошибки let statusCode = 500; let errorMessage = 'Ошибка при обновлении страницы'; if (error.message && error.message.includes('timeout exceeded when trying to connect')) { statusCode = 503; // Service Unavailable errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.'; } else if (error.code === '23505') { // PostgreSQL unique violation statusCode = 409; // Conflict errorMessage = 'Страница с такими данными уже существует'; } else if (error.message) { errorMessage = error.message; } res.status(statusCode).json({ success: false, error: errorMessage, message: errorMessage }); } }); // Удалить страницу по id router.delete('/:id', async (req, res) => { if (!req.session || !req.session.authenticated) { return res.status(401).json({ error: 'Требуется аутентификация' }); } if (!req.session.address) { return res.status(403).json({ error: 'Требуется подключение кошелька' }); } // Проверяем роль админа через токены в кошельке const authService = require('../services/auth-service'); const userAccessLevel = await authService.getUserAccessLevel(req.session.address); if (!userAccessLevel.hasAccess) { return res.status(403).json({ error: 'Only admin can delete pages' }); } const tableName = `admin_pages_simple`; const pageId = parseInt(req.params.id); const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' }); // Сначала получаем информацию о странице перед удалением const pageResult = await db.getQuery()( `SELECT * FROM ${tableName} WHERE id = $1`, [pageId] ); if (!pageResult.rows.length) return res.status(404).json({ error: 'Page not found' }); const pageToDelete = pageResult.rows[0]; // Находим все медиа-файлы, связанные с этой страницей try { const mediaResult = await db.getQuery()( `SELECT id, file_hash FROM content_media WHERE page_id = $1`, [pageId] ); const deletedMediaCount = mediaResult.rows.length; console.log(`[pages] Найдено ${deletedMediaCount} медиа-файлов, связанных со страницей ${pageId}`); // Для каждого медиа-файла проверяем, используется ли он в других местах for (const media of mediaResult.rows) { if (media.file_hash) { // Проверяем, сколько раз используется этот файл (по file_hash) const usageResult = await db.getQuery()( `SELECT COUNT(*) as count FROM content_media WHERE file_hash = $1`, [media.file_hash] ); const usageCount = parseInt(usageResult.rows[0].count); // Если файл используется только один раз (только в этой странице), удаляем его полностью if (usageCount === 1) { await db.getQuery()( `DELETE FROM content_media WHERE id = $1`, [media.id] ); console.log(`[pages] Удален медиа-файл ID ${media.id} (file_hash: ${media.file_hash}), использовался только в удаляемой странице`); } else { // Если файл используется в других местах, просто убираем связь со страницей await db.getQuery()( `UPDATE content_media SET page_id = NULL WHERE id = $1`, [media.id] ); console.log(`[pages] Убрана связь медиа-файла ID ${media.id} со страницей ${pageId} (файл используется в ${usageCount} местах)`); } } else { // Если file_hash отсутствует, просто удаляем файл await db.getQuery()( `DELETE FROM content_media WHERE id = $1`, [media.id] ); console.log(`[pages] Удален медиа-файл ID ${media.id} (без file_hash)`); } } if (deletedMediaCount > 0) { console.log(`[pages] Обработано ${deletedMediaCount} медиа-файлов при удалении страницы ${pageId}`); } } catch (mediaError) { console.error('[pages] Ошибка при удалении медиа-файлов:', mediaError); // Продолжаем удаление страницы даже если произошла ошибка с медиа-файлами } // Удаляем страницу const { rows } = await db.getQuery()( `DELETE FROM ${tableName} WHERE id = $1 RETURNING *`, [pageId] ); if (!rows.length) return res.status(404).json({ error: 'Page not found' }); const deleted = rows[0]; // Удаляем из векторного поиска try { if (deleted && deleted.format === 'html') { // Удаляем документ и все его чанки const rowIdsToDelete = [String(deleted.id)]; // Основной документ // Удаляем возможные чанки (до 100 чанков на документ) for (let i = 0; i < 100; i++) { rowIdsToDelete.push(`${deleted.id}_chunk_${i}`); } await vectorSearchClient.remove('legal_docs', rowIdsToDelete); console.log(`[pages] Удалены документ ${deleted.id} и все его чанки из векторного поиска`); } } catch (e) { console.error('[pages] vector remove error:', e.message); } res.json(deleted); }); // Публичные маршруты для просмотра страниц (доступны всем пользователям) // Получить все опубликованные страницы router.get('/public/all', async (req, res) => { try { const tableName = `admin_pages_simple`; // Проверяем, есть ли таблица const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) { return res.json([]); } // Поддержка фильтрации по категории и родителю const { category, parent_id, search } = req.query; let whereClause = `WHERE visibility = 'public' AND status = 'published'`; const params = []; let paramIndex = 1; if (category) { whereClause += ` AND category = $${paramIndex}`; params.push(category); paramIndex++; } if (parent_id !== undefined) { if (parent_id === null || parent_id === 'null' || parent_id === '') { whereClause += ` AND parent_id IS NULL`; } else { const parsed = parseInt(parent_id); if (!isNaN(parsed)) { whereClause += ` AND parent_id = $${paramIndex}`; params.push(parsed); paramIndex++; } } } if (search) { whereClause += ` AND (title ILIKE $${paramIndex} OR summary ILIKE $${paramIndex})`; params.push(`%${search}%`); paramIndex++; } // Сортировка: сначала по категории, затем по order_index, затем по created_at const { rows } = await db.getQuery()(` SELECT * FROM ${tableName} ${whereClause} ORDER BY COALESCE(category, '') ASC, COALESCE(order_index, 0) ASC, created_at DESC `, params); console.log(`[pages] GET /public/all: найдено ${rows.length} публичных документов`); if (rows.length > 0) { console.log(`[pages] Примеры документов:`, rows.slice(0, 3).map(r => ({ id: r.id, title: r.title, category: r.category }))); } res.json(rows); } catch (error) { console.error('Ошибка получения публичных страниц:', error); // Возвращаем пустой массив вместо объекта с ошибкой, чтобы фронтенд не ломался console.error('[pages] GET /public/all: ошибка, возвращаем пустой массив'); res.status(500).json([]); } }); // Получить иерархическую структуру всех публичных страниц router.get('/public/structure', async (req, res) => { try { const tableName = `admin_pages_simple`; const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) { return res.json({ categories: [] }); } // Получаем все опубликованные публичные страницы const { rows } = await db.getQuery()(` SELECT id, title, summary, category, parent_id, order_index, nav_path, is_index_page, created_at FROM ${tableName} WHERE visibility = 'public' AND status = 'published' ORDER BY COALESCE(category, '') ASC, COALESCE(order_index, 0) ASC, created_at ASC `); // Группируем по категориям и строим иерархию const categories = {}; const pagesById = {}; console.log(`[pages] GET /public/structure: найдено ${rows.length} страниц`); rows.forEach(page => { pagesById[page.id] = { ...page, children: [] }; // Нормализуем категорию: приводим к нижнему регистру для консистентности const cat = (page.category && String(page.category).trim()) ? String(page.category).trim().toLowerCase() : 'uncategorized'; if (!categories[cat]) { categories[cat] = { name: cat, pages: [] }; } }); // Строим иерархию rows.forEach(page => { const pageObj = pagesById[page.id]; if (page.parent_id && pagesById[page.parent_id]) { // Дочерний документ - добавляем в children родителя pagesById[page.parent_id].children.push(pageObj); } else { // Родительский документ (без parent_id) - добавляем в категорию // Нормализуем категорию: приводим к нижнему регистру для консистентности const cat = (page.category && String(page.category).trim()) ? String(page.category).trim().toLowerCase() : 'uncategorized'; // Убеждаемся, что категория существует (на случай, если она была создана только в первом цикле) if (!categories[cat]) { categories[cat] = { name: cat, pages: [] }; } categories[cat].pages.push(pageObj); } }); // Сортируем children внутри каждого родительского документа Object.values(categories).forEach(cat => { cat.pages.forEach(page => { if (page.children && Array.isArray(page.children) && page.children.length > 0) { page.children.sort((a, b) => { if (a.order_index !== b.order_index) { return (a.order_index || 0) - (b.order_index || 0); } return new Date(a.created_at) - new Date(b.created_at); }); } }); }); // Загружаем все категории из таблицы document_categories (включая пустые) try { const categoriesRes = await db.getQuery()( `SELECT name, display_name, description, order_index FROM document_categories ORDER BY order_index, created_at` ); categoriesRes.rows.forEach(cat => { const normalizedName = cat.name.toLowerCase(); if (!categories[normalizedName]) { // Добавляем пустую категорию, если её нет в списке из документов categories[normalizedName] = { name: normalizedName, pages: [] }; } // Обновляем отображаемое название if (cat.display_name) { categories[normalizedName].display_name = cat.display_name; } }); } catch (err) { console.warn('[pages] Ошибка загрузки категорий из document_categories (таблица может не существовать):', err.message); } console.log(`[pages] GET /public/structure: создано ${Object.keys(categories).length} категорий:`, Object.keys(categories)); // Сортируем страницы в категориях: сначала родительские (с детьми), потом остальные Object.values(categories).forEach(cat => { cat.pages.sort((a, b) => { // Сначала документы с детьми (родительские) const aHasChildren = a.children && Array.isArray(a.children) && a.children.length > 0; const bHasChildren = b.children && Array.isArray(b.children) && b.children.length > 0; // Если a имеет детей, а b нет - a идет первым (отрицательное значение = a перед b) if (aHasChildren && !bHasChildren) return -1; // Если b имеет детей, а a нет - b идет первым (положительное значение = b перед a) if (!aHasChildren && bHasChildren) return 1; // Если оба с детьми или оба без детей, сортируем по order_index и created_at if (a.order_index !== b.order_index) { return (a.order_index || 0) - (b.order_index || 0); } return new Date(a.created_at) - new Date(b.created_at); }); // Логируем результат сортировки для отладки console.log(`[pages] Категория "${cat.name}": порядок документов:`, cat.pages.map(p => ({ id: p.id, title: p.title, hasChildren: p.children && Array.isArray(p.children) && p.children.length > 0, childrenCount: p.children ? p.children.length : 0 })) ); }); const result = { categories: Object.values(categories), totalPages: rows.length }; // Логируем результат для отладки console.log(`[pages] GET /public/structure: возвращаем ${result.categories.length} категорий`); result.categories.forEach(cat => { console.log(` - ${cat.name}: ${cat.pages.length} страниц`); }); res.json(result); } catch (error) { console.error('Ошибка получения структуры страниц:', error); // Возвращаем пустую структуру вместо объекта с ошибкой, чтобы фронтенд не ломался console.error('[pages] GET /public/structure: ошибка, возвращаем пустую структуру'); res.status(500).json({ categories: [], totalPages: 0 }); } }); // Получить навигацию для конкретного документа router.get('/public/:id/navigation', async (req, res) => { try { const tableName = `admin_pages_simple`; const pageId = parseInt(req.params.id); const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) { return res.status(404).json({ error: 'Страница не найдена' }); } // Получаем текущую страницу const { rows: currentPage } = await db.getQuery()(` SELECT * FROM ${tableName} WHERE id = $1 AND visibility = 'public' AND status = 'published' `, [pageId]); if (currentPage.length === 0) { return res.status(404).json({ error: 'Страница не найдена' }); } const page = currentPage[0]; const category = page.category || null; const parentId = page.parent_id || null; // Получаем все страницы той же категории/родителя для навигации let whereClause = `WHERE visibility = 'public' AND status = 'published'`; const params = []; let paramIndex = 1; if (parentId) { whereClause += ` AND parent_id = $${paramIndex}`; params.push(parentId); paramIndex++; } else if (category) { whereClause += ` AND category = $${paramIndex} AND (parent_id IS NULL OR parent_id = 0)`; params.push(category); paramIndex++; } else { whereClause += ` AND (category IS NULL OR category = '') AND (parent_id IS NULL OR parent_id = 0)`; } const { rows: siblings } = await db.getQuery()(` SELECT id, title, nav_path, order_index FROM ${tableName} ${whereClause} ORDER BY COALESCE(order_index, 0) ASC, created_at ASC `, params); // Находим текущую страницу в списке const currentIndex = siblings.findIndex(p => p.id === pageId); const prevPage = currentIndex > 0 ? siblings[currentIndex - 1] : null; const nextPage = currentIndex < siblings.length - 1 ? siblings[currentIndex + 1] : null; // Получаем родительскую страницу let parentPage = null; if (parentId) { const { rows: parent } = await db.getQuery()(` SELECT id, title, nav_path FROM ${tableName} WHERE id = $1 AND visibility = 'public' AND status = 'published' `, [parentId]); if (parent.length > 0) { parentPage = parent[0]; } } // Формируем breadcrumbs const breadcrumbs = []; if (category) { breadcrumbs.push({ name: category, path: null }); } if (parentPage) { breadcrumbs.push({ name: parentPage.title, path: `/content/published/${parentPage.id}` }); } breadcrumbs.push({ name: page.title, path: null }); res.json({ previous: prevPage ? { id: prevPage.id, title: prevPage.title, path: `/content/published/${prevPage.id}` } : null, next: nextPage ? { id: nextPage.id, title: nextPage.title, path: `/content/published/${nextPage.id}` } : null, parent: parentPage ? { id: parentPage.id, title: parentPage.title, path: `/content/published/${parentPage.id}` } : null, breadcrumbs }); } catch (error) { console.error('Ошибка получения навигации:', error); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Внутренние документы (доступны всем аутентифицированным пользователям с подключенным кошельком) router.get('/internal/all', async (req, res) => { try { if (!req.session || !req.session.authenticated) { return res.status(401).json({ error: 'Требуется аутентификация' }); } if (!req.session.address) { return res.status(403).json({ error: 'Требуется подключение кошелька' }); } const tableName = `admin_pages_simple`; const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) { return res.json([]); } const authService = require('../services/auth-service'); const userAccessLevel = await authService.getUserAccessLevel(req.session.address); // Все аутентифицированные пользователи с подключенным кошельком могут видеть внутренние страницы // EDITOR может видеть все (включая черновики), обычные пользователи - только опубликованные const role = userAccessLevel.level; // 'user' | 'readonly' | 'editor' let sql; if (role === 'editor') { // Редактор видит все внутренние страницы, включая черновики sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' ORDER BY created_at DESC`; } else { // Обычные пользователи видят только опубликованные внутренние страницы sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' AND status = 'published' ORDER BY created_at DESC`; } const { rows } = await db.getQuery()(sql); res.json(rows); } catch (error) { console.error('Ошибка получения внутренних страниц:', error); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); // Получить одну опубликованную страницу по id router.get('/public/:id', async (req, res) => { try { const tableName = `admin_pages_simple`; // Проверяем, есть ли таблица const existsRes = await db.getQuery()( `SELECT to_regclass($1) as exists`, [tableName] ); if (!existsRes.rows[0].exists) { return res.status(404).json({ error: 'Страница не найдена или не опубликована' }); } // Ищем страницу среди всех админов const { rows } = await db.getQuery()(` SELECT * FROM ${tableName} WHERE id = $1 AND status = 'published' `, [req.params.id]); if (rows.length > 0) { return res.json(rows[0]); } res.status(404).json({ error: 'Страница не найдена или не опубликована' }); } catch (error) { console.error('Ошибка получения публичной страницы:', error); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); module.exports = router;