forked from admin/zeropost-engine
feat: series API — тематические подборки статей
- БД: таблица series (slug, title, intro, icon, color, article_ids JSONB, is_featured, sort_order) - routes/series.js: CRUD серий, GET /:slug возвращает серию вместе со статьями (через JOIN по array_position для сохранения порядка) - Индекс idx_series_slug
This commit is contained in:
@@ -9,6 +9,7 @@ const postsRoutes = require('./src/routes/posts');
|
|||||||
const articlesRoutes = require('./src/routes/articles');
|
const articlesRoutes = require('./src/routes/articles');
|
||||||
const statsRoutes = require('./src/routes/stats');
|
const statsRoutes = require('./src/routes/stats');
|
||||||
const notesRoutes = require('./src/routes/notes');
|
const notesRoutes = require('./src/routes/notes');
|
||||||
|
const seriesRoutes = require('./src/routes/series');
|
||||||
|
|
||||||
// Start queue worker
|
// Start queue worker
|
||||||
require('./src/workers/generation');
|
require('./src/workers/generation');
|
||||||
@@ -40,6 +41,7 @@ app.use('/api/posts', postsRoutes);
|
|||||||
app.use('/api/articles', articlesRoutes);
|
app.use('/api/articles', articlesRoutes);
|
||||||
app.use('/api/stats', statsRoutes);
|
app.use('/api/stats', statsRoutes);
|
||||||
app.use('/api/notes', notesRoutes);
|
app.use('/api/notes', notesRoutes);
|
||||||
|
app.use('/api/series', seriesRoutes);
|
||||||
|
|
||||||
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() });
|
||||||
|
|||||||
@@ -157,6 +157,23 @@ const migrate = async () => {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// series — тематические серии статей
|
||||||
|
await query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS series (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slug VARCHAR(120) UNIQUE NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
intro TEXT,
|
||||||
|
icon VARCHAR(40),
|
||||||
|
color VARCHAR(20) DEFAULT 'emerald',
|
||||||
|
article_ids JSONB DEFAULT '[]'::jsonb,
|
||||||
|
is_featured BOOLEAN DEFAULT false,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// индексы
|
// индексы
|
||||||
await query(`
|
await query(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
|
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
|
||||||
@@ -167,6 +184,7 @@ const migrate = async () => {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug);
|
CREATE INDEX IF NOT EXISTS idx_articles_slug ON articles(slug);
|
||||||
CREATE INDEX IF NOT EXISTS idx_articles_status_pub ON articles(status, published_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_articles_status_pub ON articles(status, published_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_notes_pub ON editor_notes(is_published, created_at DESC);
|
CREATE INDEX IF NOT EXISTS idx_notes_pub ON editor_notes(is_published, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_series_slug ON series(slug);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log('[DB] Migrations applied');
|
console.log('[DB] Migrations applied');
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { query } = require('../config/db');
|
||||||
|
|
||||||
|
// GET /api/series — список серий
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT s.*,
|
||||||
|
(SELECT COUNT(*) FROM articles a
|
||||||
|
WHERE a.id::text = ANY(SELECT jsonb_array_elements_text(s.article_ids))
|
||||||
|
AND a.status='published') as articles_count
|
||||||
|
FROM series s ORDER BY sort_order ASC, created_at ASC`
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/series/:slug — серия со статьями
|
||||||
|
router.get('/:slug', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows: sRows } = await query(`SELECT * FROM series WHERE slug=$1`, [req.params.slug]);
|
||||||
|
if (!sRows.length) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const series = sRows[0];
|
||||||
|
|
||||||
|
const ids = (series.article_ids || []).map(Number).filter(Boolean);
|
||||||
|
let articles = [];
|
||||||
|
if (ids.length) {
|
||||||
|
const { rows: aRows } = await query(
|
||||||
|
`SELECT id, slug, title, excerpt, cover_url, tags, reading_time, published_at
|
||||||
|
FROM articles WHERE id = ANY($1::int[]) AND status='published'
|
||||||
|
ORDER BY array_position($1::int[], id) ASC`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
articles = aRows;
|
||||||
|
}
|
||||||
|
res.json({ ...series, articles });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/series — создать
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { slug, title, intro, icon, color, article_ids = [], is_featured, sort_order } = req.body;
|
||||||
|
if (!slug || !title) return res.status(400).json({ error: 'slug and title required' });
|
||||||
|
const { rows } = await query(
|
||||||
|
`INSERT INTO series (slug, title, intro, icon, color, article_ids, is_featured, sort_order)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||||
|
[slug, title, intro || null, icon || null, color || 'emerald',
|
||||||
|
JSON.stringify(article_ids), !!is_featured, sort_order || 0]
|
||||||
|
);
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/series/:id
|
||||||
|
router.patch('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, intro, icon, color, article_ids, is_featured, sort_order } = req.body;
|
||||||
|
const { rows } = await query(
|
||||||
|
`UPDATE series SET
|
||||||
|
title=COALESCE($1,title),
|
||||||
|
intro=COALESCE($2,intro),
|
||||||
|
icon=COALESCE($3,icon),
|
||||||
|
color=COALESCE($4,color),
|
||||||
|
article_ids=COALESCE($5::jsonb,article_ids),
|
||||||
|
is_featured=COALESCE($6,is_featured),
|
||||||
|
sort_order=COALESCE($7,sort_order),
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE id=$8 RETURNING *`,
|
||||||
|
[title, intro, icon, color,
|
||||||
|
article_ids !== undefined ? JSON.stringify(article_ids) : null,
|
||||||
|
is_featured, sort_order, req.params.id]
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/series/:id
|
||||||
|
router.delete('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await query(`DELETE FROM series WHERE id=$1`, [req.params.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
Reference in New Issue
Block a user