feat(categories): CRUD endpoints + is_active for archiving
Endpoints under /api/admin/categories:
GET — list all with metrics (article_count, topic_count, autogen status)
POST — create + auto-create autogen_settings row (defaults: enabled, 1/day, 12:00 MSK)
PATCH — update name/desc/icon/color/sort_order/is_active (slug is immutable FK)
DELETE — soft (is_active=false) by default; ?force=true tries hard delete
but refuses if any articles/topics still reference the slug
Migration: ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true
Public GET /api/categories now filters WHERE COALESCE(is_active,true)=true.
This commit is contained in:
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user