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:
@@ -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() });
|
||||||
|
|||||||
@@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 }); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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