feat: draft review flow — autogen→draft, auto-approve 07:00 MSK, /api/drafts routes

This commit is contained in:
Nik (Claude)
2026-06-16 09:17:10 +03:00
parent cd471d67a9
commit 5852b9f439
4 changed files with 162 additions and 77 deletions
+55
View File
@@ -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 };
+5
View File
@@ -12,6 +12,7 @@ const notesRoutes = require('./src/routes/notes');
const seriesRoutes = require('./src/routes/series'); const seriesRoutes = require('./src/routes/series');
const categoriesRoutes = require('./src/routes/categories'); const categoriesRoutes = require('./src/routes/categories');
const autogenRoutes = require('./src/routes/autogen'); const autogenRoutes = require('./src/routes/autogen');
const draftsRoutes = require('./src/routes/drafts');
const userPostsRoutes = require('./src/routes/userPosts'); const userPostsRoutes = require('./src/routes/userPosts');
const settingsRoutes = require('./src/routes/settings'); const settingsRoutes = require('./src/routes/settings');
const photoSearchRoutes = require('./src/routes/photo-search'); const photoSearchRoutes = require('./src/routes/photo-search');
@@ -110,6 +111,7 @@ app.use('/api/notes', notesRoutes);
app.use('/api/series', seriesRoutes); app.use('/api/series', seriesRoutes);
app.use('/api/categories', categoriesRoutes); app.use('/api/categories', categoriesRoutes);
app.use('/api/autogen', autogenRoutes); app.use('/api/autogen', autogenRoutes);
app.use('/api/drafts', draftsRoutes);
app.use('/api/user-posts', userPostsRoutes); app.use('/api/user-posts', userPostsRoutes);
app.use('/api/settings', settingsRoutes); app.use('/api/settings', settingsRoutes);
app.use('/api/photo-search', photoSearchRoutes); app.use('/api/photo-search', photoSearchRoutes);
@@ -136,6 +138,9 @@ const start = async () => {
// Автоматический ретрай SVG-заглушек // Автоматический ретрай SVG-заглушек
require('./src/services/coverRetry').start(); require('./src/services/coverRetry').start();
// Авто-одобрение черновиков в 07:00 МСК
require('./draftAutoApprove').startDraftAutoApproveScheduler();
// Ежедневные авто-черновики (каждые 30 мин проверяем каналы с auto_draft_enabled) // Ежедневные авто-черновики (каждые 30 мин проверяем каналы с auto_draft_enabled)
const draftSvc = require('./src/services/draftService'); const draftSvc = require('./src/services/draftService');
setInterval(async () => { setInterval(async () => {
+100 -75
View File
@@ -1,101 +1,126 @@
/** // Роуты для работы с черновиками (draft review flow)
* 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 — удалить
*/
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { query } = require('../config/db'); const { query } = require('../config/db');
const channelsSvc = require('../services/channels'); const { scheduleForArticle } = require('../services/articleAutoPublish');
const draftSvc = require('../services/draftService'); const { generateCover, COVER_STYLES } = require('../services/covers');
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;
// GET /api/drafts — список черновиков
router.get('/', async (req, res) => {
try { try {
const channel = await channelsSvc.getFullChannel(req.params.channelId); const { rows } = await query(
if (!channel) return res.status(404).json({ error: 'Channel not found' }); `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({ ok: true, message: `Генерирую ${count} черновиков...`, count }); );
res.json(rows);
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}`));
} catch (err) { } catch (err) {
if (!res.headersSent) res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}); });
// GET /api/drafts — все черновики текущего пользователя // GET /api/drafts/cover-styles — доступные стили обложки
router.get('/drafts', async (req, res) => { router.get('/cover-styles', (req, res) => {
const userId = uid(req); res.json(COVER_STYLES.map(s => ({ id: s.name, name: s.name })));
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/:channelId/channel — черновики конкретного канала // PATCH /api/drafts/:id — редактировать черновик (title, content, excerpt)
router.get('/drafts/:channelId/channel', async (req, res) => { router.patch('/:id', 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 { try {
const result = await draftSvc.listDrafts({ channelId: req.params.channelId, status, limit: parseInt(limit), offset: parseInt(offset) }); const id = parseInt(req.params.id);
res.json(result); const { title, excerpt, content } = req.body;
} catch (err) { res.status(500).json({ error: err.message }); } 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 — редактировать // PATCH /api/drafts/:id/approve — одобрить черновик вручную
router.patch('/drafts/:id', async (req, res) => { router.patch('/:id/approve', async (req, res) => {
try { try {
await draftSvc.updateDraft(req.params.id, req.body); const id = parseInt(req.params.id);
res.json({ ok: true }); const { rows } = await query(
} catch (err) { res.status(500).json({ error: err.message }); } `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 // POST /api/drafts/:id/regenerate-cover — перегенерировать обложку
router.post('/drafts/:id/approve', async (req, res) => { router.post('/:id/regenerate-cover', async (req, res) => {
const userId = uid(req);
try { try {
const result = await draftSvc.approveDraft(req.params.id, { const id = parseInt(req.params.id);
scheduledAt: req.body.scheduled_at, const { style } = req.body; // необязательно
userId,
// Берём статью
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 // POST /api/drafts/approve-all — авто-одобрение всех (ручной вызов)
router.post('/drafts/:id/reject', async (req, res) => { router.post('/approve-all', async (req, res) => {
try { try {
await draftSvc.rejectDraft(req.params.id); const { runDraftAutoApprove } = require('../../draftAutoApprove');
await runDraftAutoApprove();
res.json({ ok: true }); res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); } } 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 }); }
}); });
module.exports = router; module.exports = router;
+1 -1
View File
@@ -112,7 +112,7 @@ async function runAutogenForCategory(category) {
topic, topic,
tags: tags, tags: tags,
keywords, keywords,
autoPublish: true, autoPublish: false, // draft review flow
category, category,
}); });