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;