feat: Зеро-персонаж, auto-publish, auto-series, channel-stats, fallback covers

- Персонаж Зеро: 23 позы (zeroCharacter.js), скрипты генерации
- Auto-publish статей в TG: multipart upload, кнопки, режим alternating Zero/cover
- Fallback цепочка обложек: aiprimetech gpt-5.5 → Pollinations → local SVG (6 палитр)
- Auto-series: Claude haiku определяет серию для каждой статьи автоматически
- Channel stats: подписчики, история, delta 24h/7d
- Photo-search: Yandex API, профили доменов, Redis лимиты
- Scheduled posts runner: backfill, preview, queue, cancel
- promptBuilder: author_persona Зеро, голос от первого лица
- Fixes: dollar-placeholder bugs в PATCH channels/autogen, listArticles фильтры
- AI model: gpt-5.5 для image generation
This commit is contained in:
Nik (Claude)
2026-06-07 14:03:56 +03:00
parent 8968eed3e0
commit a370b8f7d8
33 changed files with 2695 additions and 147 deletions
+94 -2
View File
@@ -1,6 +1,8 @@
const express = require('express');
const router = express.Router();
const articlesSvc = require('../services/articles');
const autoPublish = require('../services/articleAutoPublish');
const autoSeries = require('../services/articleAutoSeries');
const { query } = require('../config/db');
// GET /api/articles — список опубликованных
@@ -23,6 +25,15 @@ router.get('/tags', async (_, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/articles/home — данные для главной страницы (hero, byCategory, popular, recent)
router.get('/home', async (req, res) => {
try {
const data = await articlesSvc.getHomeArticles();
res.json(data);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/articles/admin — все статьи для админки (включая черновики)
router.get('/admin', async (req, res) => {
try {
@@ -38,6 +49,61 @@ router.get('/admin', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/articles/admin/search — typeahead-поиск по статьям.
// Параметры: q (подстрока в title), status (default published), category, limit (default 20),
// channel_id (если задан — пометит already_in_channel, was_published_in_channel)
router.get('/admin/search', async (req, res) => {
try {
const q = (req.query.q || '').trim();
const status = req.query.status || 'published';
const category = req.query.category || null;
const limit = Math.min(parseInt(req.query.limit) || 20, 50);
const channelId = req.query.channel_id ? parseInt(req.query.channel_id) : null;
const params = [];
let where = [];
if (status && status !== 'any') { params.push(status); where.push(`status=$${params.length}`); }
if (category) { params.push(category); where.push(`category=$${params.length}`); }
if (q) { params.push(`%${q.toLowerCase()}%`); where.push(`lower(title) LIKE $${params.length}`); }
params.push(limit);
const sql = `
SELECT id, slug, title, excerpt, cover_url, category, status, published_at
FROM articles
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
ORDER BY published_at DESC NULLS LAST, created_at DESC
LIMIT $${params.length}`;
const { rows: items } = await query(sql, params);
// Если задан channel_id — для каждого item ищем, был ли уже опубликован в этом канале (через scheduled_posts.status='sent')
if (channelId && items.length) {
const ids = items.map(a => a.id);
const { rows: sent } = await query(
`SELECT article_id, MAX(published_at) AS last_sent_at
FROM scheduled_posts
WHERE channel_id=$1 AND article_id = ANY($2::int[]) AND status='sent'
GROUP BY article_id`,
[channelId, ids]
);
const sentMap = Object.fromEntries(sent.map(r => [r.article_id, r.last_sent_at]));
const { rows: pending } = await query(
`SELECT article_id, MIN(scheduled_at) AS next_scheduled_at
FROM scheduled_posts
WHERE channel_id=$1 AND article_id = ANY($2::int[]) AND status='pending'
GROUP BY article_id`,
[channelId, ids]
);
const pendingMap = Object.fromEntries(pending.map(r => [r.article_id, r.next_scheduled_at]));
for (const it of items) {
it.was_sent_to_channel = sentMap[it.id] || null;
it.next_scheduled_at = pendingMap[it.id] || null;
}
}
res.json({ items, count: items.length });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/articles/id/:id — одна статья по числовому id
router.get('/id/:id', async (req, res) => {
try {
@@ -50,9 +116,18 @@ router.get('/id/:id', async (req, res) => {
// POST /api/articles/generate
router.post('/generate', async (req, res) => {
try {
const { topic, keywords = [], tags = [], autoPublish = true, category = 'ai-tools' } = req.body;
const { topic, keywords = [], tags = [], autoPublish: autoPub = true, category = 'ai-tools' } = req.body;
if (!topic) return res.status(400).json({ error: 'topic is required' });
const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish, category });
const article = await articlesSvc.generateAndSaveArticle({ topic, keywords, tags, autoPublish: autoPub, category });
// Hook: автопубликация в каналы
if (article && article.status === 'published') {
autoPublish.scheduleForArticle(article.id).catch(err => {
console.error('[articles] auto-publish hook failed:', err.message);
});
autoSeries.addToSeries(article.id).catch(err => {
console.error('[articles] auto-series hook failed:', err.message);
});
}
res.json(article);
} catch (err) {
console.error('[Articles] generate', err);
@@ -84,11 +159,28 @@ router.patch('/:id', async (req, res) => {
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
fields.push(`updated_at=NOW()`);
vals.push(req.params.id);
// Сначала проверим прежний status — чтобы понимать, был ли переход draft → published
const { rows: prevRows } = await query(`SELECT status FROM articles WHERE id=$1`, [req.params.id]);
const prevStatus = prevRows[0]?.status;
const { rows } = await query(
`UPDATE articles SET ${fields.join(', ')} WHERE id=$${i} RETURNING *`,
vals
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
// Hook: если статья только что стала published
const newStatus = rows[0].status;
if (newStatus === 'published' && prevStatus !== 'published') {
autoPublish.scheduleForArticle(rows[0].id).catch(err => {
console.error('[articles] auto-publish hook failed:', err.message);
});
autoSeries.addToSeries(rows[0].id).catch(err => {
console.error('[articles] auto-series hook failed:', err.message);
});
}
res.json(rows[0]);
} catch (err) { res.status(500).json({ error: err.message }); }
});
+5 -5
View File
@@ -28,13 +28,13 @@ router.patch('/settings/:category', async (req, res) => {
try {
const { enabled, per_day, run_hour, run_minute } = req.body;
const fields = []; const vals = []; let i = 1;
if (enabled !== undefined) { fields.push(`enabled=${i++}`); vals.push(enabled); }
if (per_day !== undefined) { fields.push(`per_day=${i++}`); vals.push(per_day); }
if (run_hour !== undefined) { fields.push(`run_hour=${i++}`); vals.push(run_hour); }
if (run_minute !== undefined) { fields.push(`run_minute=${i++}`); vals.push(run_minute); }
if (enabled !== undefined) { fields.push(`enabled=$${i++}`); vals.push(enabled); }
if (per_day !== undefined) { fields.push(`per_day=$${i++}`); vals.push(per_day); }
if (run_hour !== undefined) { fields.push(`run_hour=$${i++}`); vals.push(run_hour); }
if (run_minute !== undefined) { fields.push(`run_minute=$${i++}`); vals.push(run_minute); }
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
vals.push(req.params.category);
await query(`UPDATE autogen_settings SET ${fields.join(',')} WHERE category=${i}`, vals);
await query(`UPDATE autogen_settings SET ${fields.join(',')} WHERE category=$${i}`, vals);
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
+31
View File
@@ -0,0 +1,31 @@
const express = require('express');
const router = express.Router();
const stats = require('../services/channelStats');
const { query } = require('../config/db');
// POST /api/channel-stats/collect — собрать статистику (cron, раз в час)
router.post('/collect', async (req, res) => {
try {
const results = await stats.collectAll();
res.json({ ok: true, results });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/channel-stats/:channelId/summary — сводка по каналу
router.get('/:channelId/summary', async (req, res) => {
try {
const summary = await stats.getChannelSummary(parseInt(req.params.channelId));
res.json(summary);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/channel-stats/:channelId/history?days=30 — история подписчиков
router.get('/:channelId/history', async (req, res) => {
try {
const days = Math.min(parseInt(req.query.days) || 30, 365);
const history = await stats.getMembersHistory(parseInt(req.params.channelId), days);
res.json(history);
} catch (err) { res.status(500).json({ error: err.message }); }
});
module.exports = router;
+104 -102
View File
@@ -1,6 +1,8 @@
const express = require('express');
const router = express.Router();
const channelsSvc = require('../services/channels');
const settings = require('../services/settings');
const autoPublish = require('../services/articleAutoPublish');
const { query } = require('../config/db');
const getUserId = (req) => {
@@ -50,7 +52,6 @@ router.post('/admin', async (req, res) => {
vk_group_id||null, vk_access_token||null, max_channel_id||null, max_access_token||null,
niche||null, audience||null, goal]
);
// Создаём style и schedule по умолчанию
await query(`INSERT INTO channel_style (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]);
await query(`INSERT INTO channel_schedule (channel_id) VALUES ($1) ON CONFLICT DO NOTHING`, [rows[0].id]);
res.json(rows[0]);
@@ -60,13 +61,18 @@ router.post('/admin', async (req, res) => {
// PATCH /api/channels/admin/:id — обновить системный канал
router.patch('/admin/:id', async (req, res) => {
try {
const allowed = ['name','platform','tg_channel_id','tg_username','bot_token',
const allowed = [
'name','platform','tg_channel_id','tg_username','bot_token',
'vk_group_id','vk_access_token','max_channel_id','max_access_token',
'niche','audience','goal','is_active'];
'niche','audience','goal','is_active',
// autopublish
'auto_publish_enabled','auto_publish_categories','auto_publish_delay_min',
'auto_publish_template','auto_publish_with_cover','auto_publish_button_text','auto_publish_image_source',
];
const fields = []; const vals = []; let i = 1;
for (const key of allowed) {
if (req.body[key] !== undefined) {
fields.push(`${key}=${i++}`);
fields.push(`${key}=$${i++}`);
vals.push(req.body[key]);
}
}
@@ -74,7 +80,7 @@ router.patch('/admin/:id', async (req, res) => {
fields.push(`updated_at=NOW()`);
vals.push(req.params.id);
const { rows } = await query(
`UPDATE channels SET ${fields.join(',')} WHERE id=${i} AND is_system=true RETURNING *`,
`UPDATE channels SET ${fields.join(',')} WHERE id=$${i} AND is_system=true RETURNING *`,
vals
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
@@ -90,45 +96,40 @@ router.delete('/admin/:id', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/channels/admin/:id/publish — опубликовать статью в канал
// POST /api/channels/admin/:id/publish — опубликовать статью в канал ПРЯМО СЕЙЧАС
// Использует channel.auto_publish_template (если есть) и channel.auto_publish_with_cover.
router.post('/admin/:id/publish', async (req, res) => {
try {
const { article_id, custom_text } = req.body;
const { article_id, custom_text, with_cover } = req.body;
const { rows } = await query(`SELECT * FROM channels WHERE id=$1 AND is_system=true`, [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Channel not found' });
const channel = rows[0];
let text = custom_text;
// Если текст не передан — берём статью и генерируем пост
if (!text && article_id) {
const { rows: arts } = await query(`SELECT * FROM articles WHERE id=$1`, [article_id]);
if (!arts.length) return res.status(404).json({ error: 'Article not found' });
const art = arts[0];
// Простой текст поста из заголовка и excerpt
text = `*${art.title}*\n\n${art.excerpt || ''}\n\nhttps://zeropost.ru/blog/${art.slug}`;
}
if (!text) return res.status(400).json({ error: 'text or article_id required' });
// Создаём временный scheduled_post на NOW и сразу запускаем runner на него.
const { rows: spRows } = await query(
`INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at, status)
VALUES ($1,$2,$3,NOW(),'pending') RETURNING *`,
[channel.id, article_id || null, custom_text || null]
);
const sp = spRows[0];
const result = { ok: true, platform: channel.platform, text };
// Telegram
if (channel.platform === 'telegram' && channel.bot_token && channel.tg_channel_id) {
const axios = require('axios');
const tgRes = await axios.post(
`https://api.telegram.org/bot${channel.bot_token}/sendMessage`,
{ chat_id: channel.tg_channel_id, text, parse_mode: 'Markdown', disable_web_page_preview: false },
{ timeout: 15000 }
);
result.tg_message_id = tgRes.data?.result?.message_id;
// Сохраняем пост
// Точечный запуск
const runner = require('../services/scheduledPostsRunner');
try {
const { messageId } = await runner.publishOne(sp);
await query(
`INSERT INTO posts (channel_id, content, status, published_at, tg_message_id)
VALUES ($1,$2,'published',NOW(),$3)`,
[channel.id, text, result.tg_message_id || null]
`UPDATE scheduled_posts SET status='sent', published_at=NOW(), error=NULL WHERE id=$1`,
[sp.id]
);
return res.json({ ok: true, platform: channel.platform, tg_message_id: messageId || null, scheduled_post_id: sp.id });
} catch (err) {
const msg = err.response?.data?.description || err.response?.data?.error?.error_msg || err.message;
await query(
`UPDATE scheduled_posts SET status='failed', error=$1 WHERE id=$2`,
[String(msg).slice(0, 1000), sp.id]
);
return res.status(500).json({ error: msg });
}
res.json(result);
} catch (err) {
const msg = err.response?.data?.description || err.message;
res.status(500).json({ error: msg });
@@ -146,70 +147,6 @@ router.get('/admin/:id/posts', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
router.get('/', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channels = await channelsSvc.listChannels(userId);
res.json(channels);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/channels/:id — один канал со всеми настройками
router.get('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.getFullChannel(req.params.id, userId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
res.json(channel);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/channels — создать канал
router.post('/', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.createChannel(userId, req.body);
res.json(channel);
} catch (err) {
console.error('[Route] POST /channels', err);
res.status(500).json({ error: err.message });
}
});
// PATCH /api/channels/:id — обновить
router.patch('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.updateChannel(req.params.id, userId, req.body);
res.json(channel);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/channels/:id
router.delete('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
await channelsSvc.deleteChannel(req.params.id, userId);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
// ── Publish slots ─────────────────────────────────────────────────────────────
// GET /api/channels/admin/:id/slots
@@ -251,11 +188,11 @@ router.delete('/admin/:id/slots/:slotId', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/channels/admin/:id/scheduled — запланированные посты
// GET /api/channels/admin/:id/scheduled — запланированные посты канала (pending+failed+sent последние)
router.get('/admin/:id/scheduled', async (req, res) => {
try {
const { rows } = await query(
`SELECT sp.*, a.title as article_title, a.slug as article_slug
`SELECT sp.*, a.title as article_title, a.slug as article_slug, a.category as article_category
FROM scheduled_posts sp
LEFT JOIN articles a ON a.id = sp.article_id
WHERE sp.channel_id=$1
@@ -267,15 +204,80 @@ router.get('/admin/:id/scheduled', async (req, res) => {
});
// POST /api/channels/admin/:id/schedule — поставить пост в очередь
// scheduled_at: если не передан — берём ближайший слот канала (через autoPublish.pickScheduleTime).
router.post('/admin/:id/schedule', async (req, res) => {
try {
const { article_id, custom_text, scheduled_at } = req.body;
if (!scheduled_at) return res.status(400).json({ error: 'scheduled_at required' });
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=$1 AND is_system=true`, [req.params.id]);
if (!chs.length) return res.status(404).json({ error: 'Channel not found' });
const when = scheduled_at ? new Date(scheduled_at) : await autoPublish.pickScheduleTime(chs[0]);
const { rows } = await query(
`INSERT INTO scheduled_posts (channel_id, article_id, custom_text, scheduled_at)
VALUES ($1,$2,$3,$4) RETURNING *`,
[req.params.id, article_id || null, custom_text || null, scheduled_at]
[req.params.id, article_id || null, custom_text || null, when]
);
res.json(rows[0]);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// ── User routes (НЕ системные, для tool) ──────────────────────────────────────
router.get('/', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channels = await channelsSvc.listChannels(userId);
res.json(channels);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.get('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.getFullChannel(req.params.id, userId);
if (!channel) return res.status(404).json({ error: 'Channel not found' });
res.json(channel);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.createChannel(userId, req.body);
res.json(channel);
} catch (err) {
console.error('[Route] POST /channels', err);
res.status(500).json({ error: err.message });
}
});
router.patch('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
const channel = await channelsSvc.updateChannel(req.params.id, userId, req.body);
res.json(channel);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.delete('/:id', async (req, res) => {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'x-user-id required' });
try {
await channelsSvc.deleteChannel(req.params.id, userId);
res.json({ ok: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+51
View File
@@ -0,0 +1,51 @@
const express = require('express');
const router = express.Router();
const photoSearch = require('../services/photo-search');
// GET /api/photo-search/quota
router.get('/quota', async (req, res) => {
try {
const data = await photoSearch.getQuotaStatus();
res.json(data);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/photo-search/by-query
// Body: { query: string, profile?: 'sports'|'general'|..., num?: 1..20 }
router.post('/by-query', async (req, res) => {
try {
const { query, profile, num } = req.body || {};
if (!query || typeof query !== 'string') {
return res.status(400).json({ error: 'query (string) is required' });
}
const result = await photoSearch.searchByQuery({
query: query.trim(),
profileSlug: profile || 'general',
num: Math.min(Math.max(parseInt(num) || 6, 1), 20),
});
res.json(result);
} catch (err) {
if (err.code === 'DAILY_LIMIT_EXCEEDED') {
return res.status(429).json({ error: err.message, code: err.code });
}
console.error('[photo-search] by-query failed:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/photo-search/profiles — список профилей (для UI селектора)
router.get('/profiles', async (req, res) => {
try {
const { query } = require('../config/db');
const { rows } = await query(
'SELECT id, slug, name, description, domains FROM photo_search_profiles ORDER BY id'
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;
+2 -1
View File
@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router();
const { query } = require('../config/db');
const axios = require('axios');
const settings = require('../services/settings');
// POST /api/posts/publish - publish a post to Telegram immediately
router.post('/publish', async (req, res) => {
@@ -18,7 +19,7 @@ router.post('/publish', async (req, res) => {
const ch = rows[0];
if (!ch.bot_token || !ch.tg_channel_id) return res.status(400).json({ error: 'Channel has no bot_token or tg_channel_id' });
const tgRes = await axios.post(`https://api.telegram.org/bot${ch.bot_token}/sendMessage`, {
const tgRes = await axios.post(`${await settings.get('TELEGRAM_API_BASE', 'https://api.telegram.org')}/bot${ch.bot_token}/sendMessage`, {
chat_id: ch.tg_channel_id,
text: content,
parse_mode: 'HTML',
+135
View File
@@ -0,0 +1,135 @@
const express = require('express');
const router = express.Router();
const runner = require('../services/scheduledPostsRunner');
const autoPublish = require('../services/articleAutoPublish');
const { query } = require('../config/db');
// POST /api/scheduled-posts/run-scheduled — обработать очередь (cron)
router.post('/run-scheduled', async (req, res) => {
try {
const result = await runner.runScheduled();
res.json(result);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/scheduled-posts/preview — пред-просмотр текста по шаблону
router.post('/preview', async (req, res) => {
try {
const { article_id, template } = req.body;
if (!article_id) return res.status(400).json({ error: 'article_id required' });
const { rows } = await query(`SELECT * FROM articles WHERE id=$1`, [article_id]);
if (!rows.length) return res.status(404).json({ error: 'Article not found' });
const text = runner.renderTemplate(template, rows[0]);
res.json({
text,
cover_url: rows[0].cover_url || null,
length: text.length,
caption_safe: text.length <= 1024,
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/scheduled-posts/schedule-article/:articleId — вручную поставить уже опубликованную статью в очередь
router.post('/schedule-article/:articleId', async (req, res) => {
try {
const created = await autoPublish.scheduleForArticle(req.params.articleId);
res.json({ ok: true, scheduled: created.length, items: created });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// GET /api/scheduled-posts/queue — общая очередь по всем каналам
router.get('/queue', async (req, res) => {
try {
const { rows } = await query(
`SELECT sp.*, c.name AS channel_name, c.platform,
a.title AS article_title, a.slug AS article_slug, a.category
FROM scheduled_posts sp
JOIN channels c ON c.id = sp.channel_id
LEFT JOIN articles a ON a.id = sp.article_id
WHERE sp.status IN ('pending','failed')
ORDER BY sp.scheduled_at ASC LIMIT 100`
);
res.json(rows);
} catch (err) { res.status(500).json({ error: err.message }); }
});
// DELETE /api/scheduled-posts/:id — отменить запланированный пост
router.delete('/:id', async (req, res) => {
try {
const { rowCount } = await query(
`DELETE FROM scheduled_posts WHERE id=$1 AND status='pending'`,
[req.params.id]
);
if (!rowCount) return res.status(404).json({ error: 'Not found or already sent' });
res.json({ ok: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// POST /api/scheduled-posts/backfill-channel/:channelId
// Заливает все опубликованные статьи (или только из категорий канала) в очередь
// с заданным интервалом. Дубли (уже отправленные / уже в очереди) пропускаются.
// Body: { interval_min?: 3, limit?: 50, order?: 'asc'|'desc', categories?: string[] }
router.post('/backfill-channel/:channelId', async (req, res) => {
try {
const channelId = parseInt(req.params.channelId);
const intervalMin = parseInt(req.body?.interval_min) || 3;
const limit = Math.min(parseInt(req.body?.limit) || 50, 100);
const order = (req.body?.order === 'desc') ? 'DESC' : 'ASC';
const categories = Array.isArray(req.body?.categories) ? req.body.categories : null;
const { rows: chs } = await query(`SELECT * FROM channels WHERE id=$1`, [channelId]);
if (!chs.length) return res.status(404).json({ error: 'Channel not found' });
const channel = chs[0];
// Берём статьи: published, не in queue/sent в этом канале
const params = [channelId];
let sql = `
SELECT a.id, a.slug, a.title, a.category, a.published_at
FROM articles a
WHERE a.status='published'
AND NOT EXISTS (
SELECT 1 FROM scheduled_posts sp
WHERE sp.channel_id=$1 AND sp.article_id=a.id AND sp.status IN ('pending','sent')
)`;
if (categories && categories.length) {
params.push(categories);
sql += ` AND a.category = ANY($${params.length}::text[])`;
}
// Если у канала задан фильтр категорий — учитываем его (но только если categories не передан явно)
else if (Array.isArray(channel.auto_publish_categories) && channel.auto_publish_categories.length) {
params.push(channel.auto_publish_categories);
sql += ` AND a.category = ANY($${params.length}::text[])`;
}
sql += ` ORDER BY a.published_at ${order} LIMIT ${limit}`;
const { rows: arts } = await query(sql, params);
const now = Date.now();
const created = [];
for (let i = 0; i < arts.length; i++) {
const when = new Date(now + (i + 1) * intervalMin * 60_000);
const { rows: ins } = await query(
`INSERT INTO scheduled_posts (channel_id, article_id, scheduled_at, status)
VALUES ($1,$2,$3,'pending') RETURNING *`,
[channelId, arts[i].id, when]
);
created.push({
scheduled_post_id: ins[0].id,
article_id: arts[i].id,
title: arts[i].title,
scheduled_at: when,
});
}
res.json({
ok: true,
channel: { id: channel.id, name: channel.name },
scheduled: created.length,
first_at: created[0]?.scheduled_at || null,
last_at: created[created.length - 1]?.scheduled_at || null,
items: created,
});
} catch (err) { res.status(500).json({ error: err.message }); }
});
module.exports = router;
+35
View File
@@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const settings = require('../services/settings');
// GET /api/settings/admin?category=photo_search — список всех настроек, опц. фильтр.
router.get('/admin', async (req, res) => {
try {
const rows = await settings.list();
const cat = req.query.category;
const filtered = cat ? rows.filter(r => r.category === cat) : rows;
res.json(filtered);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// PUT /api/settings/admin/:key — обновить значение одной настройки
router.put('/admin/:key', async (req, res) => {
try {
const { value } = req.body || {};
const row = await settings.set(req.params.key, value ?? null);
if (!row) return res.status(404).json({ error: 'Setting key not found' });
res.json(row);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/settings/admin/invalidate — принудительно сбросить кэш
router.post('/admin/invalidate', async (req, res) => {
settings.invalidate();
res.json({ ok: true });
});
module.exports = router;
+4 -2
View File
@@ -24,11 +24,13 @@ router.post('/', async (req, res) => {
try {
const userId = getUserId(req);
if (!userId) return res.status(401).json({ error: 'Unauthorized' });
const { channel_id, content, image_url, topic, status, scheduled_at } = req.body;
const { channel_id, content, image_url, image_credit, topic, status, scheduled_at } = req.body;
if (!channel_id || !content) return res.status(400).json({ error: 'channel_id and content required' });
const post = await svc.savePost({
userId, channelId: channel_id, content,
imageUrl: image_url, topic,
imageUrl: image_url,
imageCredit: image_credit ?? null,
topic,
status: status || 'draft',
scheduledAt: scheduled_at,
});