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