diff --git a/draftAutoApprove.js b/draftAutoApprove.js new file mode 100644 index 0000000..545f90d --- /dev/null +++ b/draftAutoApprove.js @@ -0,0 +1,55 @@ +// draftAutoApprove.js +// Каждый день в 07:00 МСК переводит все статьи status='draft' → 'published' +// и ставит их на ближайшие publish_slots канала 1. + +const { query } = require('./src/config/db'); +const { scheduleForArticle } = require('./src/services/articleAutoPublish'); + +const AUTO_APPROVE_HOUR_MSK = 7; +let lastRunDate = null; + +async function runDraftAutoApprove() { + try { + const { rows: drafts } = await query( + `SELECT id, title, category FROM articles WHERE status='draft' ORDER BY created_at ASC` + ); + + if (!drafts.length) { + console.log('[DraftApprove] no drafts to approve'); + return; + } + + console.log(`[DraftApprove] approving ${drafts.length} drafts`); + + for (const draft of drafts) { + await query( + `UPDATE articles SET status='published', published_at=NOW() WHERE id=$1`, + [draft.id] + ); + await scheduleForArticle(draft.id); + console.log(`[DraftApprove] approved article=${draft.id} "${draft.title.slice(0, 50)}"`); + } + } catch (err) { + console.error('[DraftApprove] error:', err.message); + } +} + +function startDraftAutoApproveScheduler() { + console.log('[DraftApprove] scheduler started (auto-approve at 07:00 MSK)'); + + setInterval(() => { + const now = new Date(); + const msk = new Date(now.getTime() + 3 * 60 * 60 * 1000); + const hour = msk.getUTCHours(); + const minute = msk.getUTCMinutes(); + const dateStr = msk.toISOString().slice(0, 10); + + if (hour === AUTO_APPROVE_HOUR_MSK && minute === 0 && lastRunDate !== dateStr) { + lastRunDate = dateStr; + console.log(`[DraftApprove] triggered at ${msk.toISOString()}`); + runDraftAutoApprove(); + } + }, 60_000); +} + +module.exports = { startDraftAutoApproveScheduler, runDraftAutoApprove }; diff --git a/index.js b/index.js index bf9c72b..ca52dad 100644 --- a/index.js +++ b/index.js @@ -12,6 +12,7 @@ const notesRoutes = require('./src/routes/notes'); const seriesRoutes = require('./src/routes/series'); const categoriesRoutes = require('./src/routes/categories'); const autogenRoutes = require('./src/routes/autogen'); +const draftsRoutes = require('./src/routes/drafts'); const userPostsRoutes = require('./src/routes/userPosts'); const settingsRoutes = require('./src/routes/settings'); const photoSearchRoutes = require('./src/routes/photo-search'); @@ -110,6 +111,7 @@ app.use('/api/notes', notesRoutes); app.use('/api/series', seriesRoutes); app.use('/api/categories', categoriesRoutes); app.use('/api/autogen', autogenRoutes); +app.use('/api/drafts', draftsRoutes); app.use('/api/user-posts', userPostsRoutes); app.use('/api/settings', settingsRoutes); app.use('/api/photo-search', photoSearchRoutes); @@ -136,6 +138,9 @@ const start = async () => { // Автоматический ретрай SVG-заглушек require('./src/services/coverRetry').start(); + // Авто-одобрение черновиков в 07:00 МСК + require('./draftAutoApprove').startDraftAutoApproveScheduler(); + // Ежедневные авто-черновики (каждые 30 мин проверяем каналы с auto_draft_enabled) const draftSvc = require('./src/services/draftService'); setInterval(async () => { diff --git a/src/routes/drafts.js b/src/routes/drafts.js index 19ecf29..eb0cef7 100644 --- a/src/routes/drafts.js +++ b/src/routes/drafts.js @@ -1,101 +1,126 @@ -/** - * drafts.js — API для черновиков постов. - * - * POST /api/channels/:channelId/drafts/generate?count=3 — batch генерация - * GET /api/drafts — все черновики юзера - * GET /api/drafts/:channelId/channel — черновики канала - * PATCH /api/drafts/:id — редактировать текст - * POST /api/drafts/:id/approve — одобрить → scheduled_post - * POST /api/drafts/:id/reject — отклонить - * DELETE /api/drafts/:id — удалить - */ +// Роуты для работы с черновиками (draft review flow) const express = require('express'); const router = express.Router(); -const { query } = require('../config/db'); -const channelsSvc = require('../services/channels'); -const draftSvc = require('../services/draftService'); - -function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; } - -// POST /api/channels/:channelId/drafts/generate -router.post('/channels/:channelId/drafts/generate', async (req, res) => { - const userId = uid(req); - if (!userId) return res.status(401).json({ error: 'x-user-id required' }); - - const count = Math.min(parseInt(req.query.count || req.body.count || 3), 10); - const withImage = req.body.withImage !== false; +const { query } = require('../config/db'); +const { scheduleForArticle } = require('../services/articleAutoPublish'); +const { generateCover, COVER_STYLES } = require('../services/covers'); +// GET /api/drafts — список черновиков +router.get('/', async (req, res) => { try { - const channel = await channelsSvc.getFullChannel(req.params.channelId); - if (!channel) return res.status(404).json({ error: 'Channel not found' }); - - // Запускаем асинхронно — отвечаем сразу - res.json({ ok: true, message: `Генерирую ${count} черновиков...`, count }); - - draftSvc.generateBatch(channel, { count, userId, withImage }) - .then(r => console.log(`[drafts] batch done ch=${channel.id}: ${r.generated} ok, ${r.errors.length} err`)) - .catch(e => console.error(`[drafts] batch error: ${e.message}`)); + const { rows } = await query( + `SELECT id, slug, title, excerpt, cover_url, category, tags, reading_time, created_at + FROM articles WHERE status='draft' + ORDER BY created_at DESC` + ); + res.json(rows); } catch (err) { - if (!res.headersSent) res.status(500).json({ error: err.message }); + res.status(500).json({ error: err.message }); } }); -// GET /api/drafts — все черновики текущего пользователя -router.get('/drafts', async (req, res) => { - const userId = uid(req); - if (!userId) return res.status(401).json({ error: 'x-user-id required' }); - const { status = 'pending', limit = 30, offset = 0 } = req.query; - try { - const result = await draftSvc.listDrafts({ userId, status, limit: parseInt(limit), offset: parseInt(offset) }); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } +// GET /api/drafts/cover-styles — доступные стили обложки +router.get('/cover-styles', (req, res) => { + res.json(COVER_STYLES.map(s => ({ id: s.name, name: s.name }))); }); -// GET /api/drafts/:channelId/channel — черновики конкретного канала -router.get('/drafts/:channelId/channel', async (req, res) => { - const userId = uid(req); - if (!userId) return res.status(401).json({ error: 'x-user-id required' }); - const { status = 'pending', limit = 30, offset = 0 } = req.query; +// PATCH /api/drafts/:id — редактировать черновик (title, content, excerpt) +router.patch('/:id', async (req, res) => { try { - const result = await draftSvc.listDrafts({ channelId: req.params.channelId, status, limit: parseInt(limit), offset: parseInt(offset) }); - res.json(result); - } catch (err) { res.status(500).json({ error: err.message }); } + const id = parseInt(req.params.id); + const { title, excerpt, content } = req.body; + const fields = [], vals = []; + if (title !== undefined) { fields.push(`title=$${vals.push(title)}`); } + if (excerpt !== undefined) { fields.push(`excerpt=$${vals.push(excerpt)}`); } + if (content !== undefined) { fields.push(`content=$${vals.push(content)}`); } + if (!fields.length) return res.status(400).json({ error: 'Nothing to update' }); + vals.push(id); + const { rows } = await query( + `UPDATE articles SET ${fields.join(',')} WHERE id=$${vals.length} AND status='draft' RETURNING id, title, slug`, + vals + ); + if (!rows.length) return res.status(404).json({ error: 'Draft not found' }); + res.json({ ok: true, article: rows[0] }); + } catch (err) { + res.status(500).json({ error: err.message }); + } }); -// PATCH /api/drafts/:id — редактировать -router.patch('/drafts/:id', async (req, res) => { +// PATCH /api/drafts/:id/approve — одобрить черновик вручную +router.patch('/:id/approve', async (req, res) => { try { - await draftSvc.updateDraft(req.params.id, req.body); - res.json({ ok: true }); - } catch (err) { res.status(500).json({ error: err.message }); } + const id = parseInt(req.params.id); + const { rows } = await query( + `UPDATE articles SET status='published', published_at=NOW() + WHERE id=$1 AND status='draft' + RETURNING id, title, slug`, + [id] + ); + if (!rows.length) return res.status(404).json({ error: 'Draft not found' }); + + const scheduled = await scheduleForArticle(id); + const slot = scheduled[0]?.scheduled_at; + console.log(`[DraftApprove] manual approve article=${id} "${rows[0].title.slice(0,50)}"`); + res.json({ ok: true, article: rows[0], scheduled_at: slot }); + } catch (err) { + res.status(500).json({ error: err.message }); + } }); -// POST /api/drafts/:id/approve -router.post('/drafts/:id/approve', async (req, res) => { - const userId = uid(req); +// POST /api/drafts/:id/regenerate-cover — перегенерировать обложку +router.post('/:id/regenerate-cover', async (req, res) => { try { - const result = await draftSvc.approveDraft(req.params.id, { - scheduledAt: req.body.scheduled_at, - userId, + const id = parseInt(req.params.id); + const { style } = req.body; // необязательно + + // Берём статью + const { rows: arts } = await query( + `SELECT id, title, tags FROM articles WHERE id=$1 AND status='draft'`, [id] + ); + if (!arts.length) return res.status(404).json({ error: 'Draft not found' }); + + // Получаем системный канал + const { rows: chans } = await query( + `SELECT id FROM channels WHERE is_system=true AND is_active=true LIMIT 1` + ); + const channelId = chans[0]?.id || null; + + // Если передан style — временно форсируем через channel_style + if (style && channelId) { + await query( + `UPDATE channel_style SET image_style=$1 WHERE channel_id=$2`, + [style, channelId] + ); + } + + const coverUrl = await generateCover({ + articleId: id, + title: arts[0].title, + tags: arts[0].tags || [], + channelId, }); - res.json({ ok: true, ...result }); - } catch (err) { res.status(500).json({ error: err.message }); } + + if (coverUrl) { + await query(`UPDATE articles SET cover_url=$1 WHERE id=$2`, [coverUrl, id]); + } + + console.log(`[DraftRegenCover] article=${id} cover=${coverUrl?.split('/').pop()}`); + res.json({ ok: true, cover_url: coverUrl }); + } catch (err) { + console.error('[DraftRegenCover] error:', err.message); + res.status(500).json({ error: err.message }); + } }); -// POST /api/drafts/:id/reject -router.post('/drafts/:id/reject', async (req, res) => { +// POST /api/drafts/approve-all — авто-одобрение всех (ручной вызов) +router.post('/approve-all', async (req, res) => { try { - await draftSvc.rejectDraft(req.params.id); + const { runDraftAutoApprove } = require('../../draftAutoApprove'); + await runDraftAutoApprove(); res.json({ ok: true }); - } catch (err) { res.status(500).json({ error: err.message }); } -}); - -// DELETE /api/drafts/:id -router.delete('/drafts/:id', async (req, res) => { - try { - await query('DELETE FROM post_drafts WHERE id=$1', [req.params.id]); - res.json({ ok: true }); - } catch (err) { res.status(500).json({ error: err.message }); } + } catch (err) { + res.status(500).json({ error: err.message }); + } }); module.exports = router; diff --git a/src/services/autogen.js b/src/services/autogen.js index 71ab274..35e9d73 100644 --- a/src/services/autogen.js +++ b/src/services/autogen.js @@ -112,7 +112,7 @@ async function runAutogenForCategory(category) { topic, tags: tags, keywords, - autoPublish: true, + autoPublish: false, // draft review flow category, });