From d054023a55a9c351b50343ac8cd90f1c825a5112 Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 17:36:01 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20user=5Fposts=20service=20=E2=80=94=20dr?= =?UTF-8?q?aft/scheduled/published,=20Telegram=20publish=20with=20image,?= =?UTF-8?q?=20cron-driven=20scheduled=20publication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- index.js | 2 + src/routes/userPosts.js | 89 +++++++++++++++++++++++ src/services/userPosts.js | 145 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 src/routes/userPosts.js create mode 100644 src/services/userPosts.js diff --git a/index.js b/index.js index 0c3a2f2..5ee3ceb 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 userPostsRoutes = require('./src/routes/userPosts'); // Start queue worker require('./src/workers/generation'); @@ -46,6 +47,7 @@ app.use('/api/notes', notesRoutes); app.use('/api/series', seriesRoutes); app.use('/api/categories', categoriesRoutes); app.use('/api/autogen', autogenRoutes); +app.use('/api/user-posts', userPostsRoutes); app.get('/health', (req, res) => { res.json({ ok: true, service: 'zeropost-engine', time: new Date() }); diff --git a/src/routes/userPosts.js b/src/routes/userPosts.js new file mode 100644 index 0000000..a8c55fa --- /dev/null +++ b/src/routes/userPosts.js @@ -0,0 +1,89 @@ +const express = require('express'); +const router = express.Router(); +const svc = require('../services/userPosts'); + +const getUserId = (req) => parseInt(req.headers['x-user-id']) || null; + +// GET /api/user-posts?channel_id=N&status=draft +router.get('/', async (req, res) => { + try { + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const posts = await svc.listUserPosts({ + userId, + channelId: req.query.channel_id ? parseInt(req.query.channel_id) : null, + status: req.query.status || null, + limit: parseInt(req.query.limit) || 50, + }); + res.json(posts); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/user-posts +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; + 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, + status: status || 'draft', + scheduledAt: scheduled_at, + }); + res.json(post); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// GET /api/user-posts/:id +router.get('/:id', async (req, res) => { + try { + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const post = await svc.getPost(userId, req.params.id); + if (!post) return res.status(404).json({ error: 'Not found' }); + res.json(post); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// PATCH /api/user-posts/:id +router.patch('/:id', async (req, res) => { + try { + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const post = await svc.updatePost(userId, req.params.id, req.body); + if (!post) return res.status(404).json({ error: 'Not found' }); + res.json(post); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// DELETE /api/user-posts/:id +router.delete('/:id', async (req, res) => { + try { + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + await svc.deletePost(userId, req.params.id); + res.json({ ok: true }); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/user-posts/:id/publish — опубликовать прямо сейчас +router.post('/:id/publish', async (req, res) => { + try { + const userId = getUserId(req); + if (!userId) return res.status(401).json({ error: 'Unauthorized' }); + const result = await svc.publishPost(userId, req.params.id); + res.json(result); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +// POST /api/user-posts/run-scheduled — внутренний endpoint для cron +router.post('/run-scheduled', async (req, res) => { + try { + const result = await svc.runScheduledPublications(); + res.json(result); + } catch (err) { res.status(500).json({ error: err.message }); } +}); + +module.exports = router; diff --git a/src/services/userPosts.js b/src/services/userPosts.js new file mode 100644 index 0000000..ceaf259 --- /dev/null +++ b/src/services/userPosts.js @@ -0,0 +1,145 @@ +const { query } = require('../config/db'); +const axios = require('axios'); + +/** + * Сохранить пост в базу (как черновик или сразу запланированный). + */ +async function savePost({ userId, channelId, content, imageUrl = null, topic = null, status = 'draft', scheduledAt = null }) { + const { rows } = await query( + `INSERT INTO user_posts (user_id, channel_id, content, image_url, topic, status, scheduled_at) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, + [userId, channelId, content, imageUrl, topic, status, scheduledAt] + ); + return rows[0]; +} + +async function listUserPosts({ userId, channelId = null, status = null, limit = 50 }) { + let sql = `SELECT * FROM user_posts WHERE user_id=$1`; + const params = [userId]; + if (channelId) { sql += ` AND channel_id=$${params.length + 1}`; params.push(channelId); } + if (status) { sql += ` AND status=$${params.length + 1}`; params.push(status); } + sql += ` ORDER BY created_at DESC LIMIT $${params.length + 1}`; + params.push(limit); + const { rows } = await query(sql, params); + return rows; +} + +async function getPost(userId, postId) { + const { rows } = await query(`SELECT * FROM user_posts WHERE id=$1 AND user_id=$2`, [postId, userId]); + return rows[0] || null; +} + +async function updatePost(userId, postId, data) { + const allowed = ['content','image_url','status','scheduled_at','topic']; + const fields = []; const vals = []; let i = 1; + for (const key of allowed) { + if (data[key] !== undefined) { fields.push(`${key}=$${i++}`); vals.push(data[key]); } + } + if (!fields.length) return null; + fields.push(`updated_at=NOW()`); + vals.push(postId, userId); + const { rows } = await query( + `UPDATE user_posts SET ${fields.join(',')} WHERE id=$${i++} AND user_id=$${i} RETURNING *`, + vals + ); + return rows[0]; +} + +async function deletePost(userId, postId) { + const { rowCount } = await query(`DELETE FROM user_posts WHERE id=$1 AND user_id=$2`, [postId, userId]); + return rowCount > 0; +} + +/** + * Опубликовать пост в Telegram. + */ +async function publishToTelegram(post, channel) { + if (!channel.bot_token || !channel.tg_channel_id) { + throw new Error('Telegram не настроен (нужны bot_token и tg_channel_id)'); + } + // Если есть картинка — отправляем как фото с подписью, иначе текст + if (post.image_url) { + const photoUrl = post.image_url.startsWith('http') + ? post.image_url + : `https://app.zeropost.ru${post.image_url}`; + const res = await axios.post( + `https://api.telegram.org/bot${channel.bot_token}/sendPhoto`, + { + chat_id: channel.tg_channel_id, + photo: photoUrl, + caption: post.content.slice(0, 1024), + parse_mode: 'Markdown', + }, + { timeout: 30000 } + ); + return { ok: true, message_id: res.data?.result?.message_id }; + } else { + const res = await axios.post( + `https://api.telegram.org/bot${channel.bot_token}/sendMessage`, + { + chat_id: channel.tg_channel_id, + text: post.content, + parse_mode: 'Markdown', + disable_web_page_preview: false, + }, + { timeout: 15000 } + ); + return { ok: true, message_id: res.data?.result?.message_id }; + } +} + +/** + * Опубликовать пост (выбирает платформу по channel.platform). + */ +async function publishPost(userId, postId) { + const post = await getPost(userId, postId); + if (!post) throw new Error('Post not found'); + + const { rows: chRows } = await query(`SELECT * FROM channels WHERE id=$1`, [post.channel_id]); + if (!chRows.length) throw new Error('Channel not found'); + const channel = chRows[0]; + + let result; + try { + if (channel.platform === 'telegram' || !channel.platform) { + result = await publishToTelegram(post, channel); + } else { + throw new Error(`Платформа ${channel.platform} пока не поддерживается`); + } + await query( + `UPDATE user_posts SET status='published', published_at=NOW(), tg_message_id=$1, error=NULL WHERE id=$2`, + [result.message_id || null, postId] + ); + return { ok: true, message_id: result.message_id }; + } catch (err) { + const msg = err.response?.data?.description || err.message; + await query( + `UPDATE user_posts SET status='failed', error=$1 WHERE id=$2`, + [msg, postId] + ); + throw new Error(msg); + } +} + +/** + * Запустить публикацию запланированных постов (вызывается cron-ом). + */ +async function runScheduledPublications() { + const { rows } = await query( + `SELECT * FROM user_posts + WHERE status='scheduled' AND scheduled_at <= NOW() + ORDER BY scheduled_at LIMIT 50` + ); + const results = []; + for (const post of rows) { + try { + const res = await publishPost(post.user_id, post.id); + results.push({ id: post.id, ok: true, message_id: res.message_id }); + } catch (err) { + results.push({ id: post.id, ok: false, error: err.message }); + } + } + return { processed: rows.length, results }; +} + +module.exports = { savePost, listUserPosts, getPost, updatePost, deletePost, publishPost, runScheduledPublications };