From bc2d311e5946c0a3395d8112514d39dc186a13e5 Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 10:05:28 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20editor=5Fnotes=20+=20/api/stats/live=20?= =?UTF-8?q?+=20tokens=20=D0=B2=20getArticleBySlug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - БД: таблица editor_notes (title/content/author/tags/is_pinned/is_published) - routes/notes.js: CRUD заметок редактора - /api/stats/live: latest article, processing job, активность за 7 дней - getArticleBySlug: JOIN с generation_jobs для tokens_in/out --- index.js | 2 + src/config/db.js | 16 ++++++++ src/routes/notes.js | 89 ++++++++++++++++++++++++++++++++++++++++ src/routes/stats.js | 50 ++++++++++++++++++++++ src/services/articles.js | 5 ++- 5 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/routes/notes.js diff --git a/index.js b/index.js index a8f88d6..fbdfcff 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,7 @@ const channelsRoutes = require('./src/routes/channels'); const postsRoutes = require('./src/routes/posts'); const articlesRoutes = require('./src/routes/articles'); const statsRoutes = require('./src/routes/stats'); +const notesRoutes = require('./src/routes/notes'); // Start queue worker require('./src/workers/generation'); @@ -38,6 +39,7 @@ app.use('/api/channels', channelsRoutes); app.use('/api/posts', postsRoutes); app.use('/api/articles', articlesRoutes); app.use('/api/stats', statsRoutes); +app.use('/api/notes', notesRoutes); 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 c17b444..5902b81 100644 --- a/src/config/db.js +++ b/src/config/db.js @@ -142,6 +142,21 @@ const migrate = async () => { ); `); + // editor_notes — короткие заметки от редактора-человека + await query(` + CREATE TABLE IF NOT EXISTS editor_notes ( + id SERIAL PRIMARY KEY, + title VARCHAR(255), + content TEXT NOT NULL, + author VARCHAR(100) DEFAULT 'Редактор', + tags JSONB DEFAULT '[]'::jsonb, + is_pinned BOOLEAN DEFAULT false, + is_published BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + // индексы await query(` CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id); @@ -151,6 +166,7 @@ const migrate = async () => { CREATE INDEX IF NOT EXISTS idx_jobs_user ON generation_jobs(user_id); 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); `); console.log('[DB] Migrations applied'); diff --git a/src/routes/notes.js b/src/routes/notes.js new file mode 100644 index 0000000..bb56382 --- /dev/null +++ b/src/routes/notes.js @@ -0,0 +1,89 @@ +const express = require('express'); +const router = express.Router(); +const { query } = require('../config/db'); + +// GET /api/notes — публичный список заметок +router.get('/', async (req, res) => { + try { + const limit = Math.min(parseInt(req.query.limit) || 20, 100); + const { rows } = await query( + `SELECT id, title, content, author, tags, is_pinned, created_at, updated_at + FROM editor_notes + WHERE is_published=true + ORDER BY is_pinned DESC, created_at DESC LIMIT $1`, + [limit] + ); + res.json(rows); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// GET /api/notes/:id +router.get('/:id', async (req, res) => { + try { + const { rows } = await query( + `SELECT * FROM editor_notes WHERE id=$1 AND is_published=true`, + [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/notes — создать (требует auth, для админа через app.zeropost.ru) +router.post('/', async (req, res) => { + try { + const { title, content, author, tags = [], is_pinned = false } = req.body; + if (!content) return res.status(400).json({ error: 'content required' }); + const { rows } = await query( + `INSERT INTO editor_notes (title, content, author, tags, is_pinned) + VALUES ($1,$2,$3,$4,$5) RETURNING *`, + [title || null, content, author || 'Редактор', JSON.stringify(tags), is_pinned] + ); + res.json(rows[0]); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// PATCH /api/notes/:id +router.patch('/:id', async (req, res) => { + try { + const { title, content, author, tags, is_pinned, is_published } = req.body; + const { rows } = await query( + `UPDATE editor_notes SET + title=COALESCE($1, title), + content=COALESCE($2, content), + author=COALESCE($3, author), + tags=COALESCE($4::jsonb, tags), + is_pinned=COALESCE($5, is_pinned), + is_published=COALESCE($6, is_published), + updated_at=NOW() + WHERE id=$7 RETURNING *`, + [ + title, content, author, + tags !== undefined ? JSON.stringify(tags) : null, + is_pinned, is_published, 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/notes/:id +router.delete('/:id', async (req, res) => { + try { + await query(`DELETE FROM editor_notes WHERE id=$1`, [req.params.id]); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/src/routes/stats.js b/src/routes/stats.js index abc6cfa..9bd7622 100644 --- a/src/routes/stats.js +++ b/src/routes/stats.js @@ -36,4 +36,54 @@ router.get('/', async (_, res) => { } }); +// GET /api/stats/live — «прямо сейчас»: текущая активность и активность за 7 дней +router.get('/live', async (_, res) => { + try { + // Последняя опубликованная статья + const { rows: latestRow } = await query( + `SELECT a.id, a.slug, a.title, a.published_at, j.tokens_in, j.tokens_out + FROM articles a + LEFT JOIN generation_jobs j ON j.id=a.job_id + WHERE a.status='published' + ORDER BY a.published_at DESC LIMIT 1` + ); + + // Сейчас обрабатывается? + const { rows: processing } = await query( + `SELECT id, type, topic, created_at FROM generation_jobs + WHERE status IN ('pending','processing') + ORDER BY created_at DESC LIMIT 1` + ); + + // Активность по дням за 7 дней + const { rows: byDay } = await query( + `SELECT + DATE_TRUNC('day', published_at) as day, + COUNT(*)::int as cnt + FROM articles + WHERE status='published' AND published_at > NOW() - INTERVAL '7 days' + GROUP BY day ORDER BY day ASC` + ); + + // Запоняем пропущенные дни нулями + const week = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(); + d.setDate(d.getDate() - i); + d.setHours(0, 0, 0, 0); + const dayStr = d.toISOString().slice(0, 10); + const found = byDay.find(r => r.day && new Date(r.day).toISOString().slice(0, 10) === dayStr); + week.push({ day: dayStr, cnt: found ? found.cnt : 0 }); + } + + res.json({ + latest: latestRow[0] || null, + processing: processing[0] || null, + week, + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + module.exports = router; diff --git a/src/services/articles.js b/src/services/articles.js index 953f573..2b84e24 100644 --- a/src/services/articles.js +++ b/src/services/articles.js @@ -47,7 +47,10 @@ async function listArticles({ limit = 20, offset = 0, tag = null } = {}) { async function getArticleBySlug(slug) { const { rows } = await query( - `SELECT * FROM articles WHERE slug=$1 AND status='published'`, + `SELECT a.*, j.tokens_in, j.tokens_out + FROM articles a + LEFT JOIN generation_jobs j ON j.id = a.job_id + WHERE a.slug=$1 AND a.status='published'`, [slug] ); if (!rows.length) return null;