From 116f15bf21650d2ed8f6c9033e9f2bb9e46f6845 Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 10:10:18 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20series=20API=20=E2=80=94=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - БД: таблица 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 --- index.js | 2 + src/config/db.js | 18 ++++++++ src/routes/series.js | 98 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 src/routes/series.js diff --git a/index.js b/index.js index fbdfcff..9c8b9f5 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,7 @@ const postsRoutes = require('./src/routes/posts'); const articlesRoutes = require('./src/routes/articles'); const statsRoutes = require('./src/routes/stats'); const notesRoutes = require('./src/routes/notes'); +const seriesRoutes = require('./src/routes/series'); // Start queue worker require('./src/workers/generation'); @@ -40,6 +41,7 @@ app.use('/api/posts', postsRoutes); app.use('/api/articles', articlesRoutes); app.use('/api/stats', statsRoutes); app.use('/api/notes', notesRoutes); +app.use('/api/series', seriesRoutes); app.get('/health', (req, res) => { res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); diff --git a/src/config/db.js b/src/config/db.js index 5902b81..8843af1 100644 --- a/src/config/db.js +++ b/src/config/db.js @@ -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(` 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_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_series_slug ON series(slug); `); console.log('[DB] Migrations applied'); diff --git a/src/routes/series.js b/src/routes/series.js new file mode 100644 index 0000000..a3db63a --- /dev/null +++ b/src/routes/series.js @@ -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;