feat: post drafts system — batch generation + daily auto-drafts

DB: post_drafts(channel_id, topic, text, image_url, status), channels.auto_draft_*
Engine:
  services/draftService.js: generateOneDraft, generateBatch, generateDailyDrafts,
    approveDraft(→scheduled_post), rejectDraft, updateDraft, listDrafts
  routes/drafts.js: GET/PATCH/DELETE /api/drafts/:id, /approve, /reject
    POST /api/channels/:channelId/drafts/generate?count=N (async, returns immediately)
  index.js: cron каждые 30 мин → generateDailyDrafts() для каналов с auto_draft_enabled
  channels.js: updateChannel сохраняет auto_draft_enabled/count/time
This commit is contained in:
Ник (Claude)
2026-06-12 23:47:27 +03:00
parent 5a765d27e1
commit a8ff295faa
4 changed files with 340 additions and 2 deletions
+13 -1
View File
@@ -106,7 +106,8 @@ app.use('/api/metrics', metricsRoutes);
app.use('/api/usage', usageRoutes);
app.use('/api/billing', require('./src/routes/billing'));
app.use('/api/channels', require('./src/routes/polls'));
app.use('/api', inboxRoutes); // /inbox/:id, /inbox/:id/reply, /tg-webhook/:channelId
app.use('/api', inboxRoutes);
app.use('/api', require('./src/routes/drafts'));
app.get('/health', (req, res) => {
res.json({ ok: true, service: 'zeropost-engine', time: new Date() });
@@ -120,6 +121,17 @@ const start = async () => {
// Автоматический ретрай SVG-заглушек
require('./src/services/coverRetry').start();
// Ежедневные авто-черновики (каждые 30 мин проверяем каналы с auto_draft_enabled)
const draftSvc = require('./src/services/draftService');
setInterval(async () => {
try {
const n = await draftSvc.generateDailyDrafts();
if (n > 0) console.log(`[Drafts] Daily auto-drafts: generated for ${n} channels`);
} catch (err) { console.error('[Drafts] daily error:', err.message); }
}, 30 * 60 * 1000);
// Первый запуск через 5 мин после старта
setTimeout(() => draftSvc.generateDailyDrafts().catch(() => {}), 5 * 60 * 1000);
app.listen(config.port, () => {
console.log(`[Engine] Running on port ${config.port}`);
});
+101
View File
@@ -0,0 +1,101 @@
/**
* 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 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;
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}`));
} catch (err) {
if (!res.headersSent) 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/: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;
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 }); }
});
// PATCH /api/drafts/:id — редактировать
router.patch('/drafts/:id', 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 }); }
});
// POST /api/drafts/:id/approve
router.post('/drafts/:id/approve', async (req, res) => {
const userId = uid(req);
try {
const result = await draftSvc.approveDraft(req.params.id, {
scheduledAt: req.body.scheduled_at,
userId,
});
res.json({ ok: true, ...result });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/drafts/:id/reject
router.post('/drafts/:id/reject', async (req, res) => {
try {
await draftSvc.rejectDraft(req.params.id);
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 }); }
});
module.exports = router;
+2 -1
View File
@@ -116,7 +116,8 @@ async function updateChannel(channelId, userId, data) {
if (Object.keys(channelFields).length) {
const fields = ['name', 'tg_channel_id', 'tg_username', 'bot_token',
'niche', 'audience', 'goal', 'language', 'region', 'is_active',
'vk_access_token', 'ai_style_prompt', 'image_quality'];
'vk_access_token', 'ai_style_prompt', 'image_quality',
'auto_draft_enabled', 'auto_draft_count', 'auto_draft_time'];
const updates = fields.filter(f => channelFields[f] !== undefined);
if (updates.length) {
const setClauses = updates.map((f, i) => `${f}=$${i + 1}`).join(', ');
+224
View File
@@ -0,0 +1,224 @@
/**
* draftService.js — генерация и управление черновиками постов.
*
* Два режима:
* 1. Ручной batch: POST /api/channels/:id/drafts/generate?count=N
* 2. Авто-ежедневный: cron вызывает generateDailyDrafts()
*
* Флоу:
* generateDraft(channel) → text + image → post_drafts (status=pending)
* Пользователь одобряет → scheduled_posts (status=pending)
*/
const { query } = require('../config/db');
const ai = require('./ai');
const topicBank = require('./topicBank');
const billing = require('./billing');
const postImages = require('./postImages');
const config = require('../config');
/**
* Сгенерировать один черновик для канала.
*/
async function generateOneDraft(channel, { topic, userId, withImage = true } = {}) {
// Выбираем тему
const useTopic = topic || await topicBank.nextTopic(channel.id).catch(() => null)
|| 'Интересные тенденции в теме канала';
// Генерируем текст
const postResult = await ai.generatePost(channel, {
topic: useTopic,
useCritique: true,
});
const text = postResult.content;
// Генерируем картинку если включено
let imageUrl = null;
if (withImage && channel.image_enabled) {
try {
const imgResult = await postImages.generatePostImage({
post: text, channel, style: channel.style || {},
});
imageUrl = imgResult?.url || null;
} catch (err) {
console.warn(`[draft] image gen failed for ch=${channel.id}: ${err.message}`);
}
}
// Сохраняем черновик
const { rows: [draft] } = await query(`
INSERT INTO post_drafts (channel_id, user_id, topic, text, image_url)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`, [channel.id, userId || null, useTopic, text, imageUrl]);
return draft;
}
/**
* Пакетная генерация N черновиков.
* Запускается параллельно (но не более 3 одновременно).
*/
async function generateBatch(channel, { count = 3, userId, withImage = true } = {}) {
const results = [];
const errors = [];
// Пополняем топик-банк если нужно
await topicBank.checkAndRefill(channel.id).catch(() => {});
// Генерируем по одному (параллелизм ограничен чтобы не перегрузить API)
const CONCURRENCY = 2;
for (let i = 0; i < count; i += CONCURRENCY) {
const batch = [];
for (let j = 0; j < CONCURRENCY && (i + j) < count; j++) {
batch.push(
generateOneDraft(channel, { userId, withImage })
.then(d => results.push(d))
.catch(e => errors.push(e.message))
);
}
await Promise.all(batch);
}
return { generated: results.length, errors, drafts: results };
}
/**
* Cron: генерировать ежедневные черновики для всех каналов с auto_draft_enabled.
* Запускается каждые 30 мин, генерирует только те каналы у которых пришло время.
*/
async function generateDailyDrafts() {
const now = new Date();
const hhmm = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
// Диапазон ±15 минут от текущего времени
const [h, m] = hhmm.split(':').map(Number);
const totalMin = h * 60 + m;
const { rows: channels } = await query(`
SELECT c.*, u.id as owner_id
FROM channels c
LEFT JOIN users u ON u.id = c.user_id
WHERE c.auto_draft_enabled = true
AND c.is_active = true
`);
let processed = 0;
for (const channel of channels) {
try {
const [ch, cm] = (channel.auto_draft_time || '08:00').split(':').map(Number);
const channelMin = ch * 60 + cm;
if (Math.abs(totalMin - channelMin) > 15) continue; // не время
// Проверяем не генерировали ли уже сегодня
const today = new Date(); today.setHours(0,0,0,0);
const { rows: [{ cnt }] } = await query(`
SELECT count(*)::int as cnt FROM post_drafts
WHERE channel_id=$1 AND created_at >= $2
`, [channel.id, today]);
if (cnt >= channel.auto_draft_count) {
console.log(`[drafts] ch=${channel.id} already has ${cnt} drafts today, skip`);
continue;
}
const toGenerate = channel.auto_draft_count - cnt;
console.log(`[drafts] ch=${channel.id} generating ${toGenerate} daily drafts...`);
await generateBatch(channel, {
count: toGenerate,
userId: channel.user_id,
withImage: !!channel.image_enabled,
});
processed++;
} catch (err) {
console.error(`[drafts] daily error ch=${channel.id}: ${err.message}`);
}
}
return processed;
}
/**
* Одобрить черновик — создаёт scheduled_post.
*/
async function approveDraft(draftId, { scheduledAt, userId } = {}) {
const { rows: [draft] } = await query(
'SELECT * FROM post_drafts WHERE id=$1', [draftId]
);
if (!draft) throw new Error('Draft not found');
if (draft.status !== 'pending') throw new Error(`Draft status is ${draft.status}`);
// Если время не указано — ставим на ближайший доступный слот (через 1 час)
const pubAt = scheduledAt
? new Date(scheduledAt)
: new Date(Date.now() + 60 * 60 * 1000);
const { rows: [sp] } = await query(`
INSERT INTO scheduled_posts (channel_id, custom_text, cover_url, scheduled_at, status)
VALUES ($1, $2, $3, $4, 'pending')
RETURNING id
`, [draft.channel_id, draft.text, draft.image_url || draft.image_local, pubAt]);
await query(`
UPDATE post_drafts
SET status='approved', scheduled_at=$1, reviewed_at=NOW()
WHERE id=$2
`, [pubAt, draftId]);
return { scheduled_post_id: sp.id, scheduled_at: pubAt };
}
/**
* Отклонить черновик.
*/
async function rejectDraft(draftId) {
await query(`
UPDATE post_drafts SET status='rejected', reviewed_at=NOW() WHERE id=$1
`, [draftId]);
}
/**
* Обновить текст черновика.
*/
async function updateDraft(draftId, { text, imageUrl }) {
const sets = [];
const vals = [];
if (text !== undefined) { sets.push(`text=$${vals.push(text)}`); }
if (imageUrl !== undefined) { sets.push(`image_url=$${vals.push(imageUrl)}`); }
if (!sets.length) return;
vals.push(draftId);
await query(`UPDATE post_drafts SET ${sets.join(',')} WHERE id=$${vals.length}`, vals);
}
/**
* Список черновиков для канала или пользователя.
*/
async function listDrafts({ channelId, userId, status = 'pending', limit = 30, offset = 0 }) {
const cond = [];
const vals = [];
if (channelId) { cond.push(`d.channel_id=$${vals.push(channelId)}`); }
if (userId) { cond.push(`c.user_id=$${vals.push(userId)}`); }
if (status !== 'all') { cond.push(`d.status=$${vals.push(status)}`); }
const where = cond.length ? 'WHERE ' + cond.join(' AND ') : '';
const { rows } = await query(`
SELECT d.*, c.name as channel_name, c.platform, c.tg_username
FROM post_drafts d
JOIN channels c ON c.id = d.channel_id
${where}
ORDER BY d.created_at DESC
LIMIT $${vals.push(limit)} OFFSET $${vals.push(offset)}
`, vals);
const countVals = vals.slice(0, -2);
const { rows: [{ total }] } = await query(
`SELECT count(*)::int as total FROM post_drafts d JOIN channels c ON c.id=d.channel_id ${where}`,
countVals
);
return { drafts: rows, total };
}
module.exports = {
generateOneDraft, generateBatch, generateDailyDrafts,
approveDraft, rejectDraft, updateDraft, listDrafts,
};