forked from admin/zeropost-engine
feat: user_posts service — draft/scheduled/published, Telegram publish with image, cron-driven scheduled publication
This commit is contained in:
@@ -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() });
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user