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:
Aleksei Pavlov
2026-06-19 11:55:04 +03:00
parent 2f7af84ddc
commit 59e604a67b
4 changed files with 176 additions and 1 deletions
+2
View File
@@ -11,6 +11,7 @@ const statsRoutes = require('./src/routes/stats');
const notesRoutes = require('./src/routes/notes'); const notesRoutes = require('./src/routes/notes');
const seriesRoutes = require('./src/routes/series'); const seriesRoutes = require('./src/routes/series');
const categoriesRoutes = require('./src/routes/categories'); const categoriesRoutes = require('./src/routes/categories');
const categoriesAdminRoutes = require('./src/routes/categoriesAdmin');
const autogenRoutes = require('./src/routes/autogen'); const autogenRoutes = require('./src/routes/autogen');
const draftsRoutes = require('./src/routes/drafts'); const draftsRoutes = require('./src/routes/drafts');
const userPostsRoutes = require('./src/routes/userPosts'); const userPostsRoutes = require('./src/routes/userPosts');
@@ -131,6 +132,7 @@ app.use('/api', require('./src/routes/drafts'));
// Заметки Зеро — админская часть (за internal-secret middleware) // Заметки Зеро — админская часть (за internal-secret middleware)
app.use('/api/admin/zero', require('./src/routes/zeroAdmin')); app.use('/api/admin/zero', require('./src/routes/zeroAdmin'));
app.use('/api/admin/categories', categoriesAdminRoutes);
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
+4
View File
@@ -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'; 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'); console.log('[DB] Migrations applied');
}; };
+1 -1
View File
@@ -5,7 +5,7 @@ const { query } = require('../config/db');
// GET /api/categories // GET /api/categories
router.get('/', async (_, res) => { router.get('/', async (_, res) => {
try { 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); res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); } } catch (err) { res.status(500).json({ error: err.message }); }
}); });
+169
View File
@@ -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;