diff --git a/index.js b/index.js index 1203eab..10756b7 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ const statsRoutes = require('./src/routes/stats'); const notesRoutes = require('./src/routes/notes'); const seriesRoutes = require('./src/routes/series'); const categoriesRoutes = require('./src/routes/categories'); +const categoriesAdminRoutes = require('./src/routes/categoriesAdmin'); const autogenRoutes = require('./src/routes/autogen'); const draftsRoutes = require('./src/routes/drafts'); const userPostsRoutes = require('./src/routes/userPosts'); @@ -131,6 +132,7 @@ app.use('/api', require('./src/routes/drafts')); // Заметки Зеро — админская часть (за internal-secret middleware) app.use('/api/admin/zero', require('./src/routes/zeroAdmin')); +app.use('/api/admin/categories', categoriesAdminRoutes); app.get('/health', (req, res) => { res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); diff --git a/src/config/db.js b/src/config/db.js index 2194bc2..36bc2bc 100644 --- a/src/config/db.js +++ b/src/config/db.js @@ -221,6 +221,10 @@ const migrate = async () => { CREATE INDEX IF NOT EXISTS idx_zero_notes_published ON zero_notes(published_at DESC) WHERE status='published'; `); + + // safe column alters (existing tables on prod may lack newer columns) + await query(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true`); + console.log('[DB] Migrations applied'); }; diff --git a/src/routes/categories.js b/src/routes/categories.js index c494cc6..91e9168 100644 --- a/src/routes/categories.js +++ b/src/routes/categories.js @@ -5,7 +5,7 @@ const { query } = require('../config/db'); // GET /api/categories router.get('/', async (_, res) => { try { - const { rows } = await query('SELECT * FROM categories ORDER BY sort_order'); + const { rows } = await query("SELECT * FROM categories WHERE COALESCE(is_active, true) = true ORDER BY sort_order"); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); diff --git a/src/routes/categoriesAdmin.js b/src/routes/categoriesAdmin.js new file mode 100644 index 0000000..b2cacd6 --- /dev/null +++ b/src/routes/categoriesAdmin.js @@ -0,0 +1,169 @@ +/** + * CRUD для категорий — /api/admin/categories/* + * + * Auth: глобальный x-internal-secret middleware (см. index.js). + * + * При создании категории автоматически создаём строку в autogen_settings + * с дефолтами (enabled=true, per_day=1, run_hour=12, run_minute=0). + * + * Удаление: soft через is_active=false (чтобы не сломать существующие статьи). + * Hard-delete доступен только если у категории нет статей и тем (?force=true). + */ +const express = require('express'); +const router = express.Router(); +const { query } = require('../config/db'); + +const ALLOWED_COLORS = ['emerald', 'red', 'amber', 'blue', 'purple', 'pink', 'cyan', 'orange', 'lime', 'rose', 'slate', 'neutral']; + +function validateSlug(s) { + if (!s || typeof s !== 'string') return 'slug обязателен'; + if (!/^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$/.test(s)) { + return 'slug может содержать только латиницу, цифры и тире (2-50 символов)'; + } + return null; +} + +function sanitize(body) { + const out = {}; + if (body.slug !== undefined) out.slug = String(body.slug || '').trim().toLowerCase(); + if (body.name !== undefined) out.name = String(body.name || '').trim().slice(0, 100); + if (body.description !== undefined) out.description = String(body.description || '').trim().slice(0, 500); + if (body.icon !== undefined) out.icon = String(body.icon || '').trim().slice(0, 10); + if (body.color !== undefined) { + const c = String(body.color || '').trim().toLowerCase(); + out.color = ALLOWED_COLORS.includes(c) ? c : 'emerald'; + } + if (body.sort_order !== undefined) out.sort_order = Math.max(0, parseInt(body.sort_order, 10) || 0); + if (body.is_active !== undefined) out.is_active = !!body.is_active; + return out; +} + +// GET /api/admin/categories — все категории + счётчики +router.get('/', async (_req, res) => { + try { + const { rows } = await query(` + SELECT + c.id, c.slug, c.name, c.description, c.icon, c.color, c.sort_order, c.is_active, + (SELECT COUNT(*) FROM articles a WHERE a.category = c.slug AND a.status='published') AS article_count, + (SELECT COUNT(*) FROM blog_topics bt WHERE bt.category = c.slug) AS topic_count, + (SELECT COUNT(*) FROM blog_topics bt WHERE bt.category = c.slug AND bt.is_used=false) AS topic_unused_count, + s.enabled AS autogen_enabled, s.per_day, s.run_hour, s.run_minute, s.last_run_at + FROM categories c + LEFT JOIN autogen_settings s ON s.category = c.slug + ORDER BY c.sort_order, c.id + `); + res.json({ ok: true, items: rows, count: rows.length }); + } catch (err) { + console.error('[admin/categories GET] error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// POST /api/admin/categories — создать +router.post('/', async (req, res) => { + try { + const data = sanitize(req.body || {}); + const slugErr = validateSlug(data.slug); + if (slugErr) return res.status(400).json({ error: slugErr }); + if (!data.name) return res.status(400).json({ error: 'name обязателен' }); + + // Проверим уникальность slug + const { rows: existing } = await query('SELECT id FROM categories WHERE slug=$1', [data.slug]); + if (existing.length) return res.status(409).json({ error: `Категория с slug "${data.slug}" уже существует` }); + + // Создаём категорию + const { rows: [created] } = await query(` + INSERT INTO categories (slug, name, description, icon, color, sort_order, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, [ + data.slug, + data.name, + data.description || null, + data.icon || '📝', + data.color || 'emerald', + data.sort_order ?? 99, + data.is_active ?? true, + ]); + + // Авто-создаём autogen_settings (если не существует) + await query(` + INSERT INTO autogen_settings (category, enabled, per_day, run_hour, run_minute) + VALUES ($1, true, 1, 12, 0) + ON CONFLICT (category) DO NOTHING + `, [data.slug]); + + res.status(201).json({ ok: true, category: created }); + } catch (err) { + console.error('[admin/categories POST] error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// PATCH /api/admin/categories/:id — обновить (slug менять нельзя — он связан с articles) +router.patch('/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!id) return res.status(400).json({ error: 'bad id' }); + + const data = sanitize(req.body || {}); + delete data.slug; // slug менять нельзя — он foreign key для articles, blog_topics, autogen_settings + + const keys = Object.keys(data); + if (!keys.length) return res.status(400).json({ error: 'нечего обновлять' }); + + const setSql = keys.map((k, i) => `${k} = $${i + 1}`).join(', '); + const values = keys.map(k => data[k]); + values.push(id); + + const { rows: [updated] } = await query( + `UPDATE categories SET ${setSql} WHERE id = $${values.length} RETURNING *`, + values + ); + if (!updated) return res.status(404).json({ error: 'category not found' }); + + res.json({ ok: true, category: updated }); + } catch (err) { + console.error('[admin/categories PATCH] error:', err); + res.status(500).json({ error: err.message }); + } +}); + +// DELETE /api/admin/categories/:id — soft (is_active=false), либо hard если ?force=true и нет связей +router.delete('/:id', async (req, res) => { + try { + const id = parseInt(req.params.id, 10); + if (!id) return res.status(400).json({ error: 'bad id' }); + const force = req.query.force === 'true'; + + const { rows: [cat] } = await query('SELECT * FROM categories WHERE id=$1', [id]); + if (!cat) return res.status(404).json({ error: 'category not found' }); + + if (force) { + // Hard delete — но только если ничего не привязано + const { rows: [{ cnt: articles }] } = await query( + `SELECT COUNT(*)::int AS cnt FROM articles WHERE category=$1`, [cat.slug] + ); + const { rows: [{ cnt: topics }] } = await query( + `SELECT COUNT(*)::int AS cnt FROM blog_topics WHERE category=$1`, [cat.slug] + ); + if (articles > 0 || topics > 0) { + return res.status(409).json({ + error: `Нельзя удалить полностью: ${articles} статей, ${topics} тем привязано к "${cat.slug}". Используй архивацию (is_active=false).`, + }); + } + await query(`DELETE FROM autogen_settings WHERE category=$1`, [cat.slug]); + await query(`DELETE FROM categories WHERE id=$1`, [id]); + return res.json({ ok: true, deleted: 'hard' }); + } + + // Soft delete = архивация + await query(`UPDATE categories SET is_active=false WHERE id=$1`, [id]); + res.json({ ok: true, deleted: 'soft' }); + } catch (err) { + console.error('[admin/categories DELETE] error:', err); + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router;