const express = require('express'); const router = express.Router(); const articlesSvc = require('../services/articles'); const autoPublish = require('../services/articleAutoPublish'); const autoSeries = require('../services/articleAutoSeries'); const { query } = require('../config/db'); // GET /api/articles — список опубликованных router.get('/', async (req, res) => { try { const limit = Math.min(parseInt(req.query.limit) || 20, 100); const offset = parseInt(req.query.offset) || 0; const tag = req.query.tag || null; const category = req.query.category || null; const list = await articlesSvc.listArticles({ limit, offset, tag, category }); res.json(list); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/articles/tags — топ тегов router.get('/tags', async (_, res) => { try { const tags = await articlesSvc.getAllTags(); res.json(tags); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/articles/home — данные для главной страницы (hero, byCategory, popular, recent) router.get('/home', async (req, res) => { try { const data = await articlesSvc.getHomeArticles(); res.json(data); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/articles/admin — все статьи для админки (включая черновики) router.get('/admin', async (req, res) => { try { const limit = Math.min(parseInt(req.query.limit) || 100, 200); const offset = parseInt(req.query.offset) || 0; const { rows } = await query( `SELECT id, slug, title, excerpt, cover_url, tags, category, author, reading_time, status, seo_title, seo_descr, views, published_at, created_at, updated_at FROM articles ORDER BY created_at DESC LIMIT $1 OFFSET $2`, [limit, offset] ); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/articles/admin/search — typeahead-поиск по статьям. // Параметры: q (подстрока в title), status (default published), category, limit (default 20), // channel_id (если задан — пометит already_in_channel, was_published_in_channel) router.get('/admin/search', async (req, res) => { try { const q = (req.query.q || '').trim(); const status = req.query.status || 'published'; const category = req.query.category || null; const limit = Math.min(parseInt(req.query.limit) || 20, 50); const channelId = req.query.channel_id ? parseInt(req.query.channel_id) : null; const params = []; let where = []; if (status && status !== 'any') { params.push(status); where.push(`status=$${params.length}`); } if (category) { params.push(category); where.push(`category=$${params.length}`); } if (q) { params.push(`%${q.toLowerCase()}%`); where.push(`lower(title) LIKE $${params.length}`); } params.push(limit); const sql = ` SELECT id, slug, title, excerpt, cover_url, category, status, published_at FROM articles ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY published_at DESC NULLS LAST, created_at DESC LIMIT $${params.length}`; const { rows: items } = await query(sql, params); // Если задан channel_id — для каждого item ищем, был ли уже опубликован в этом канале (через scheduled_posts.status='sent') if (channelId && items.length) { const ids = items.map(a => a.id); const { rows: sent } = await query( `SELECT article_id, MAX(published_at) AS last_sent_at FROM scheduled_posts WHERE channel_id=$1 AND article_id = ANY($2::int[]) AND status='sent' GROUP BY article_id`, [channelId, ids] ); const sentMap = Object.fromEntries(sent.map(r => [r.article_id, r.last_sent_at])); const { rows: pending } = await query( `SELECT article_id, MIN(scheduled_at) AS next_scheduled_at FROM scheduled_posts WHERE channel_id=$1 AND article_id = ANY($2::int[]) AND status='pending' GROUP BY article_id`, [channelId, ids] ); const pendingMap = Object.fromEntries(pending.map(r => [r.article_id, r.next_scheduled_at])); for (const it of items) { it.was_sent_to_channel = sentMap[it.id] || null; it.next_scheduled_at = pendingMap[it.id] || null; } } res.json({ items, count: items.length }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/articles/id/:id — одна статья по числовому id router.get('/id/:id', async (req, res) => { try { const { rows } = await query('SELECT * FROM articles WHERE id=$1', [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 }); } }); // POST /api/articles/generate router.post('/generate', async (req, res) => { try { const { topic, keywords = [], tags = [], autoPublish: autoPub = true, category = 'ai-tools', customPrompt } = req.body; if (!topic) return res.status(400).json({ error: 'topic is required' }); const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish: autoPub, category, customPrompt }); // Hook: автопубликация в каналы if (article && article.status === 'published') { autoPublish.scheduleForArticle(article.id).catch(err => { console.error('[articles] auto-publish hook failed:', err.message); }); autoSeries.addToSeries(article.id).catch(err => { console.error('[articles] auto-series hook failed:', err.message); }); } res.json(article); } catch (err) { console.error('[Articles] generate', err); res.status(500).json({ error: err.message }); } }); // POST /api/articles/backfill-covers router.post('/backfill-covers', async (req, res) => { try { const covers = require('../services/covers'); const limit = parseInt(req.body?.limit) || 3; const result = await covers.backfillCovers({ limit }); res.json(result); } catch (err) { res.status(500).json({ error: err.message }); } }); // PATCH /api/articles/:id router.patch('/:id', async (req, res) => { try { const allowed = ['title','excerpt','content','tags','status','seo_title','seo_descr','cover_url','category']; const fields = []; const vals = []; let i = 1; for (const key of allowed) { if (req.body[key] !== undefined) { fields.push(`${key}=$${i++}`); vals.push(req.body[key]); } } if (!fields.length) return res.status(400).json({ error: 'Nothing to update' }); fields.push(`updated_at=NOW()`); vals.push(req.params.id); // Сначала проверим прежний status — чтобы понимать, был ли переход draft → published const { rows: prevRows } = await query(`SELECT status FROM articles WHERE id=$1`, [req.params.id]); const prevStatus = prevRows[0]?.status; const { rows } = await query( `UPDATE articles SET ${fields.join(', ')} WHERE id=$${i} RETURNING *`, vals ); if (!rows.length) return res.status(404).json({ error: 'Not found' }); // Hook: если статья только что стала published const newStatus = rows[0].status; if (newStatus === 'published' && prevStatus !== 'published') { autoPublish.scheduleForArticle(rows[0].id).catch(err => { console.error('[articles] auto-publish hook failed:', err.message); }); autoSeries.addToSeries(rows[0].id).catch(err => { console.error('[articles] auto-series hook failed:', err.message); }); } res.json(rows[0]); } catch (err) { res.status(500).json({ error: err.message }); } }); // DELETE /api/articles/:id router.delete('/:id', async (req, res) => { try { const { rowCount } = await query('DELETE FROM articles WHERE id=$1', [req.params.id]); if (!rowCount) return res.status(404).json({ error: 'Not found' }); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/articles/:slug — одна статья по slug (ПОСЛЕДНИЙ — чтобы не перехватывал /admin, /tags и т.д.) router.get('/:slug', async (req, res) => { try { const a = await articlesSvc.getArticleBySlug(req.params.slug); if (!a) return res.status(404).json({ error: 'Not found' }); res.json(a); } catch (err) { res.status(500).json({ error: err.message }); } }); module.exports = router; // POST /api/articles/:id/regenerate-cover — перегенерация обложки для любой статьи router.post('/:id/regenerate-cover', async (req, res) => { try { const id = parseInt(req.params.id); const { rows } = await query('SELECT id, title, tags FROM articles WHERE id=$1', [id]); if (!rows.length) return res.status(404).json({ error: 'Article not found' }); await query('UPDATE articles SET cover_url=NULL WHERE id=$1', [id]); const covers = require('../services/covers'); const art = rows[0]; const coverUrl = await covers.generateCover({ articleId: id, title: art.title, tags: art.tags || [], channelId: 1, }); if (coverUrl) await query('UPDATE articles SET cover_url=$1 WHERE id=$2', [coverUrl, id]); res.json({ ok: true, cover_url: coverUrl }); } catch (err) { res.status(500).json({ error: err.message }); } });