feat(blog-topics): PATCH endpoint + soften requireAdmin + dynamic category name

- requireAdmin softened: doesn't require x-user-id when users.is_admin column
  is absent (same pattern as /api/admin/zero). Auth still enforced via the
  global x-internal-secret middleware.
- /api/admin/blog-topics/:id now supports PATCH (topic/tags/priority/is_used).
- /api/admin/blog-topics/generate reads category name from categories table
  instead of using a hardcoded 4-entry map, so AI-generation works for any
  user-created category.
This commit is contained in:
Aleksei Pavlov
2026-06-19 12:05:04 +03:00
parent 59e604a67b
commit 7b115deaa1
+44 -11
View File
@@ -9,11 +9,19 @@ const { query } = require('../config/db');
function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; }
async function requireAdmin(req, res) {
// x-internal-secret уже проверен глобальным middleware (см. index.js).
// Опционально — если есть users.is_admin (multi-user окружение, как на dev2) — проверим;
// если колонки нет (минимальный prod), доверяем секрету.
const adminId = uid(req);
if (!adminId) { res.status(401).json({ error: 'x-user-id required' }); return null; }
const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]);
if (!u?.is_admin) { res.status(403).json({ error: 'Forbidden' }); return null; }
return adminId;
if (!adminId) return 'system';
try {
const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]);
if (u && u.is_admin === false) { res.status(403).json({ error: 'Forbidden' }); return null; }
return adminId;
} catch (err) {
// колонки is_admin нет — это нормально для prod конфига
return adminId;
}
}
// GET /api/admin/dashboard
@@ -559,6 +567,29 @@ router.post('/blog-topics', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// PATCH /api/admin/blog-topics/:id — обновить тему (topic, tags, priority, is_used)
router.patch('/blog-topics/:id', async (req, res) => {
if (!await requireAdmin(req, res)) return;
const { topic, tags, priority, is_used } = req.body || {};
const fields = [];
const vals = [];
if (topic !== undefined) { fields.push(`topic=$${fields.length+1}`); vals.push(String(topic).trim()); }
if (tags !== undefined) { fields.push(`tags=$${fields.length+1}`); vals.push(Array.isArray(tags) ? tags : []); }
if (priority !== undefined) { fields.push(`priority=$${fields.length+1}`); vals.push(parseInt(priority, 10) || 5); }
if (is_used !== undefined) { fields.push(`is_used=$${fields.length+1}`); vals.push(!!is_used); }
if (!fields.length) return res.status(400).json({ error: 'нечего обновлять' });
vals.push(parseInt(req.params.id, 10));
try {
const { rows: [row] } = await query(
`UPDATE blog_topics SET ${fields.join(', ')} WHERE id=$${vals.length} RETURNING *`,
vals
);
if (!row) return res.status(404).json({ error: 'не найдено' });
res.json({ ok: true, topic: row });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// DELETE /api/admin/blog-topics/:id
router.delete('/blog-topics/:id', async (req, res) => {
if (!await requireAdmin(req, res)) return;
@@ -581,14 +612,16 @@ router.post('/blog-topics/generate', async (req, res) => {
const ai = require('../services/ai');
const config = require('../config');
const CATEGORY_NAMES = {
'ai-tools': 'AI инструменты для работы и бизнеса',
'ai-dev': 'AI разработка и программирование',
'automation': 'Автоматизация процессов',
'cybersec': 'Кибербезопасность',
};
const system = `Ты редактор tech-блога. Генерируй темы для статей категории "${CATEGORY_NAMES[category] || category}".
// Имя и описание категории берём из БД (а не hardcoded)
const { rows: [catRow] } = await query(
'SELECT name, description FROM categories WHERE slug=$1',
[category]
);
const catName = catRow?.name || category;
const catDescr = catRow?.description ? ` (${catRow.description})` : '';
const system = `Ты редактор tech-блога. Генерируй темы для статей категории "${catName}"${catDescr}.
Темы должны быть: конкретными, практическими, интересными читателям.
Формат: точные заголовки статей, не категории.
Ответь ТОЛЬКО JSON-массивом строк без markdown.`;