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
+101 -76
View File
@@ -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;