feat: новая функция

This commit is contained in:
2025-10-23 13:53:44 +03:00
parent b2e0795e8a
commit 918da882d2
15 changed files with 1327 additions and 827 deletions

View File

@@ -25,7 +25,12 @@
"deploy:modules": "node scripts/deploy/deploy-modules.js",
"generate:abi": "node scripts/generate-abi.js",
"generate:flattened": "node scripts/generate-flattened.js",
"compile:full": "npx hardhat compile && npm run generate:abi && npm run generate:flattened"
"compile:full": "npx hardhat compile && npm run generate:abi && npm run generate:flattened",
"seed:legal": "node scripts/seed/legalTemplatesSeed.js"
},
"bin": {},
"engines": {
"node": ">=18"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.51.0",

View File

@@ -13,6 +13,10 @@
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'];
@@ -70,8 +74,31 @@ async function ensureAdminPagesTable(fields) {
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('/', async (req, res) => {
router.post('/', upload.single('file'), async (req, res) => {
if (!req.session || !req.session.authenticated) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
@@ -87,20 +114,65 @@ router.post('/', async (req, res) => {
}
const authorAddress = req.session.address;
const fields = Object.keys(req.body).filter(f => !FIELDS_TO_EXCLUDE.includes(f));
const tableName = `admin_pages_simple`;
// Формируем SQL для вставки данных
const colNames = ['author_address', ...fields].join(', ');
const values = [authorAddress, ...fields.map(f => {
const value = typeof req.body[f] === 'object' ? JSON.stringify(req.body[f]) : req.body[f];
return value || '';
})];
// Собираем данные страницы
const bodyRaw = req.body || {};
const pageData = {
title: bodyRaw.title || '',
summary: bodyRaw.summary || '',
content: bodyRaw.content || '',
seo: bodyRaw.seo ? (typeof bodyRaw.seo === 'string' ? bodyRaw.seo : JSON.stringify(bodyRaw.seo)) : null,
status: bodyRaw.status || 'draft',
settings: bodyRaw.settings ? (typeof bodyRaw.settings === 'string' ? bodyRaw.settings : JSON.stringify(bodyRaw.settings)) : null,
visibility: bodyRaw.visibility || 'public',
required_permission: bodyRaw.required_permission || null,
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
};
// Формируем SQL для вставки данных (только непустые поля)
const dataEntries = Object.entries(pageData).filter(([, v]) => 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 *`;
const { rows } = await db.getQuery()(sql, values);
res.json(rows[0]);
const created = rows[0];
// Индексация в vector-search (только для HTML, если есть текст)
try {
if (created && (created.format === 'html' || pageData.format === 'html')) {
const text = stripHtml(created.content || pageData.content || '');
if (text && text.length > 0) {
const url = created.visibility === 'public' && created.status === 'published'
? `/public/page/${created.id}`
: `/content/page/${created.id}`;
await vectorSearchClient.upsert('legal_docs', [{
row_id: created.id,
text,
metadata: {
doc_id: created.id,
title: created.title,
url,
visibility: created.visibility || pageData.visibility,
required_permission: created.required_permission || pageData.required_permission,
format: created.format || pageData.format,
updated_at: created.updated_at || null
}
}]);
}
}
} catch (e) {
console.error('[pages] vector upsert error:', e.message);
}
res.json(created);
});
// Получить все страницы админов
@@ -171,8 +243,65 @@ router.get('/:id', async (req, res) => {
res.json(rows[0]);
});
// Ручная переиндексация документа в 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}`;
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 });
} catch (e) {
console.error('[pages] manual reindex error:', e.message);
res.status(500).json({ error: 'Ошибка индексации' });
}
});
// Редактировать страницу по id
router.patch('/:id', async (req, res) => {
router.patch('/:id', upload.single('file'), async (req, res) => {
if (!req.session || !req.session.authenticated) {
return res.status(401).json({ error: 'Требуется аутентификация' });
}
@@ -193,22 +322,58 @@ router.patch('/:id', async (req, res) => {
);
if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' });
const fields = Object.keys(req.body).filter(f => !FIELDS_TO_EXCLUDE.includes(f));
if (!fields.length) return res.status(400).json({ error: 'No fields to update' });
const filteredBody = {};
fields.forEach(f => {
// Преобразуем объекты в JSON строки
filteredBody[f] = typeof req.body[f] === 'object' ? JSON.stringify(req.body[f]) : req.body[f];
});
const setClause = fields.map((f, i) => `"${f}" = $${i + 1}`).join(', ');
const values = Object.values(filteredBody);
const incoming = req.body || {};
const updateData = {};
for (const [k, v] of Object.entries(incoming)) {
if (FIELDS_TO_EXCLUDE.includes(k)) continue;
updateData[k] = typeof v === 'object' ? JSON.stringify(v) : v;
}
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 = $${fields.length + 1} RETURNING *`;
const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = NOW() WHERE id = $${entries.length + 1} RETURNING *`;
const { rows } = await db.getQuery()(sql, values);
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
res.json(rows[0]);
const updated = rows[0];
// Индексация для HTML
try {
if (updated && (updated.format === 'html')) {
const text = stripHtml(updated.content || '');
if (text) {
const url = updated.visibility === 'public' && updated.status === 'published'
? `/public/page/${updated.id}`
: `/content/page/${updated.id}`;
await vectorSearchClient.upsert('legal_docs', [{
row_id: updated.id,
text,
metadata: {
doc_id: updated.id,
title: updated.title,
url,
visibility: updated.visibility,
required_permission: updated.required_permission,
format: updated.format,
updated_at: updated.updated_at || null
}
}]);
}
}
} catch (e) {
console.error('[pages] vector upsert (update) error:', e.message);
}
res.json(updated);
});
// Удалить страницу по id
@@ -238,7 +403,15 @@ router.delete('/:id', async (req, res) => {
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
res.json(rows[0]);
const deleted = rows[0];
try {
if (deleted && deleted.format === 'html') {
await vectorSearchClient.remove('legal_docs', [deleted.id]);
}
} catch (e) {
console.error('[pages] vector remove error:', e.message);
}
res.json(deleted);
});
// Публичные маршруты для просмотра страниц (доступны всем пользователям)
@@ -270,6 +443,44 @@ router.get('/public/all', async (req, res) => {
}
});
// Внутренние документы (доступны аутентифицированным пользователям с доступом)
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);
if (!userAccessLevel.hasAccess) {
return res.status(403).json({ error: 'Only internal users can view pages' });
}
// READONLY/EDITOR видят внутренние опубликованные; EDITOR может видеть и черновики
const role = userAccessLevel.level; // '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 {

View File

@@ -0,0 +1,227 @@
/**
* Seed системных юридических шаблонов (РКН-2025) в admin_pages_simple
* - Добавляет недостающие колонки (visibility, format и пр.)
* - Создает шаблоны с is_system_template = true и author_address = NULL
* - Повторный запуск — идемпотентен (по title + is_system_template)
*/
const db = require('../../db');
async function getExistingColumns(tableName) {
const res = await db.getQuery()(
`SELECT column_name FROM information_schema.columns WHERE table_name = $1`,
[tableName]
);
return res.rows.map(r => r.column_name);
}
async function ensureTable(tableName) {
const existsRes = await db.getQuery()(
`SELECT to_regclass($1) as exists`,
[tableName]
);
if (!existsRes.rows[0].exists) {
await db.getQuery()(`
CREATE TABLE ${tableName} (
id SERIAL PRIMARY KEY,
author_address TEXT NULL,
title TEXT,
summary TEXT,
content TEXT,
seo JSONB,
status TEXT,
visibility TEXT,
required_permission TEXT,
format TEXT,
mime_type TEXT,
storage_type TEXT,
file_path TEXT,
size_bytes BIGINT,
checksum TEXT,
is_system_template BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
`);
}
}
async function ensureColumns(tableName) {
const needed = {
author_address: 'TEXT',
title: 'TEXT',
summary: 'TEXT',
content: 'TEXT',
seo: 'JSONB',
status: 'TEXT',
visibility: 'TEXT',
required_permission: 'TEXT',
format: 'TEXT',
mime_type: 'TEXT',
storage_type: 'TEXT',
file_path: 'TEXT',
size_bytes: 'BIGINT',
checksum: 'TEXT',
is_system_template: 'BOOLEAN DEFAULT FALSE',
created_at: 'TIMESTAMP DEFAULT NOW()',
updated_at: 'TIMESTAMP DEFAULT NOW()'
};
const existing = await getExistingColumns(tableName);
for (const [col, type] of Object.entries(needed)) {
if (!existing.includes(col)) {
await db.getQuery()(`ALTER TABLE ${tableName} ADD COLUMN ${col} ${type}`);
}
}
}
function htmlEscape(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function tpl(content) {
// Лаконичный, «человеческий» текст с минимальными inlineплейсхолдерами
return `
<h1>${htmlEscape(content.title)}</h1>
<p>
Настоящий документ предназначен для использования в рамках деятельности
{{company_name}} по адресу {{company_address}} и подлежит персонализации редактором.
</p>
<p>
Ответственное лицо за вопросы персональных данных: {{responsible_person}}
(<a href="mailto:{{privacy_email}}">{{privacy_email}}</a>, {{privacy_phone}}).
</p>
<p>
Дата версии: {{date}} · Юрисдикция: {{jurisdiction}} · Язык: {{language}}
</p>
<p>
Ниже приведён текст шаблона. Перед публикацией проверьте корректность реквизитов,
правовых оснований и сроков хранения данных.
</p>
${content.body || ''}
`;
}
function doc(title, summary, visibility = 'public', requiredPermission = null) {
return {
title,
summary,
content: tpl({ title, visibility }),
seo: { title, description: summary, keywords: 'ПДн, политика, согласие' },
status: 'draft',
visibility,
required_permission: requiredPermission,
format: 'html',
mime_type: 'text/html',
storage_type: 'embedded',
is_system_template: true
};
}
async function upsertTemplate(tableName, template) {
const exists = await db.getQuery()(
`SELECT id FROM ${tableName} WHERE title = $1 AND is_system_template = TRUE LIMIT 1`,
[template.title]
);
if (exists.rows.length > 0) {
// Обновляем основные поля, не трогая author_address
const sql = `UPDATE ${tableName}
SET summary = $2, content = $3, seo = $4, status = $5, visibility = $6,
required_permission = $7, format = $8, mime_type = $9, storage_type = $10,
updated_at = NOW()
WHERE id = $1`;
await db.getQuery()(sql, [
exists.rows[0].id,
template.summary,
template.content,
JSON.stringify(template.seo || {}),
template.status,
template.visibility,
template.required_permission,
template.format,
template.mime_type,
template.storage_type
]);
return { updated: 1, inserted: 0 };
}
const sql = `INSERT INTO ${tableName}
(author_address, title, summary, content, seo, status, visibility, required_permission, format, mime_type, storage_type, is_system_template)
VALUES (NULL, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE)`;
await db.getQuery()(sql, [
template.title,
template.summary,
template.content,
JSON.stringify(template.seo || {}),
template.status,
template.visibility,
template.required_permission,
template.format,
template.mime_type,
template.storage_type
]);
return { updated: 0, inserted: 1 };
}
async function main() {
const tableName = 'admin_pages_simple';
await ensureTable(tableName);
await ensureColumns(tableName);
const publicDocs = [
doc('Политика в отношении обработки персональных данных', 'Публичная политика обработки ПДн для пользователей.', 'public'),
doc('Политика конфиденциальности', 'Публичная политика конфиденциальности сервиса.', 'public'),
doc('Согласие на обработку персональных данных', 'Шаблон пользовательского согласия на обработку ПДн.', 'public'),
doc('Согласие на использование файлов cookie', 'Шаблон согласия на использование cookie по категориям.', 'public'),
doc('Согласие на трансграничную передачу ПДн', 'Шаблон согласия на трансграничную передачу ПДн.', 'public'),
doc('Согласие на обработку биометрических ПДн', 'Шаблон согласия на обработку биометрических ПДн.', 'public'),
doc('Права субъектов ПДн и отзыв согласия', 'Информация о правах субъектов ПДн и форма отзыва согласия.', 'public')
];
const internalPermView = 'view_legal_docs';
const internalDocs = [
doc('Приказ о назначении ответственного за ПДн', 'Внутренний приказ о назначении ответственного.', 'internal', internalPermView),
doc('Должностная инструкция ответственного за ПДн', 'Обязанности и полномочия ответственного.', 'internal', internalPermView),
doc('Положение об обработке и защите ПДн', 'Локальный акт об обработке и защите ПДн.', 'internal', internalPermView),
doc('Регламент обращений субъектов ПДн', 'Порядок рассмотрения обращений субъектов.', 'internal', internalPermView),
doc('Регламент исполнения запросов субъектов', 'Доступ, исправление, удаление, ограничение.', 'internal', internalPermView),
doc('Политика хранения и уничтожения ПДн', 'Сроки хранения и процедуры уничтожения ПДн.', 'internal', internalPermView),
doc('Политика разграничения доступа к ПДн', 'Матрица ролей, уровни доступа.', 'internal', internalPermView),
doc('Перечень допущенных лиц и НДА', 'Список сотрудников/подрядчиков и обязательства о НДА.', 'internal', internalPermView),
doc('Шаблон DPA (поручение обработки ПДн)', 'Условия поручения обработки ПДн процессорам.', 'internal', internalPermView),
doc('Реестр операций по обработке ПДн', 'Цели, категории, сроки хранения, основания.', 'internal', internalPermView),
doc('Журналы учетов и инцидентов', 'Журналы доступа/операций и безопасности.', 'internal', internalPermView),
doc('Перечень и описание ИСПДн', 'Состав ИСПДн, типы и классификация.', 'internal', internalPermView),
doc('Модель угроз и меры защиты', 'Актуальная модель угроз и меры защиты.', 'internal', internalPermView),
doc('План обеспечения безопасности ПДн', 'Мероприятия по обеспечению безопасности ПДн.', 'internal', internalPermView),
doc('Регламент реагирования на инциденты', 'Порядок реагирования и план восстановления.', 'internal', internalPermView),
doc('Программа обучения и журнал инструктажей', 'Программа обучения и учет инструктажей.', 'internal', internalPermView),
doc('Уведомление РКН об обработке ПДн (шаблон)', 'Шаблон уведомления РКН об обработке ПДн.', 'internal', internalPermView),
doc('Процедуры трансграничной передачи ПДн', 'Порядок и уведомления для трансграничной передачи.', 'internal', internalPermView),
doc('Согласие ребенка/законного представителя', 'Шаблон согласия для несовершеннолетних.', 'internal', internalPermView),
doc('Политика работы с cookie и сторонними сервисами', 'Регламент для cookie/аналитики/рекламы.', 'internal', internalPermView)
];
let inserted = 0, updated = 0;
for (const t of [...publicDocs, ...internalDocs]) {
const res = await upsertTemplate(tableName, t);
inserted += res.inserted;
updated += res.updated;
}
console.log(`[seed:legal] completed. inserted=${inserted}, updated=${updated}`);
}
main().then(() => process.exit(0)).catch(err => {
console.error('[seed:legal] error:', err);
process.exit(1);
});