forked from admin/zeropost-engine
bbae6c8832
Topic bank (P6): - DB: channel_topics(channel_id, topic, is_used) - services/topicBank.js: nextTopic, refillManual, addManual, listTopics, checkAndRefill Авто-пополнение когда <5 тем, пачками по 10 через Claude Haiku - routes/generate.js: GET/POST /topics-bank/:channelId, /refill, /add, DELETE /item/:id Channel limit (P7): - routes/channels.js: POST / → проверяет billing.getBalance().channelsMax перед созданием HTTP 402 + CHANNEL_LIMIT_REACHED если лимит исчерпан - channels/new/page.js: при 402 → ошибка + redirect на /plans через 2 сек ENGINE_URL fix: 3040 → 3030 (lib/engine.js)
301 lines
12 KiB
JavaScript
301 lines
12 KiB
JavaScript
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) => {
|
|
const id = req.headers['x-user-id'];
|
|
if (!id) return null;
|
|
return parseInt(id);
|
|
};
|
|
|
|
// ── Admin routes (системные каналы zeropost, без user_id) ─────────────────────
|
|
|
|
// GET /api/channels/admin — список системных каналов
|
|
router.get('/admin', async (req, res) => {
|
|
try {
|
|
const { rows } = await query(
|
|
`SELECT c.*,
|
|
to_jsonb(s.*) - 'channel_id' - 'updated_at' AS style,
|
|
to_jsonb(sch.*) - 'channel_id' - 'updated_at' AS schedule
|
|
FROM channels c
|
|
LEFT JOIN channel_style s ON s.channel_id = c.id
|
|
LEFT JOIN channel_schedule sch ON sch.channel_id = c.id
|
|
WHERE c.is_system = true
|
|
ORDER BY c.created_at ASC`
|
|
);
|
|
res.json(rows);
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// POST /api/channels/admin — создать системный канал
|
|
router.post('/admin', async (req, res) => {
|
|
try {
|
|
const {
|
|
name, platform = 'telegram',
|
|
tg_channel_id, tg_username, bot_token,
|
|
vk_group_id, vk_access_token,
|
|
max_channel_id, max_access_token,
|
|
niche, audience, goal = 'educational',
|
|
} = req.body;
|
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
|
const { rows } = await query(
|
|
`INSERT INTO channels
|
|
(user_id, 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_system)
|
|
VALUES (0,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,true)
|
|
RETURNING *`,
|
|
[name, platform, tg_channel_id||null, tg_username||null, bot_token||null,
|
|
vk_group_id||null, vk_access_token||null, max_channel_id||null, max_access_token||null,
|
|
niche||null, audience||null, goal]
|
|
);
|
|
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]);
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// PATCH /api/channels/admin/:id — обновить системный канал
|
|
router.patch('/admin/:id', async (req, res) => {
|
|
try {
|
|
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',
|
|
// 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++}`);
|
|
vals.push(req.body[key]);
|
|
}
|
|
}
|
|
if (!fields.length) return res.status(400).json({ error: 'Nothing to update' });
|
|
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 *`,
|
|
vals
|
|
);
|
|
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
|
res.json(rows[0]);
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// DELETE /api/channels/admin/:id
|
|
router.delete('/admin/:id', async (req, res) => {
|
|
try {
|
|
await query(`DELETE FROM channels WHERE id=$1 AND is_system=true`, [req.params.id]);
|
|
res.json({ ok: true });
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// 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, 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];
|
|
|
|
// Создаём временный 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 runner = require('../services/scheduledPostsRunner');
|
|
try {
|
|
const { messageId } = await runner.publishOne(sp);
|
|
await query(
|
|
`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 });
|
|
}
|
|
} catch (err) {
|
|
const msg = err.response?.data?.description || err.message;
|
|
res.status(500).json({ error: msg });
|
|
}
|
|
});
|
|
|
|
// GET /api/channels/admin/:id/posts — история публикаций канала
|
|
router.get('/admin/:id/posts', async (req, res) => {
|
|
try {
|
|
const { rows } = await query(
|
|
`SELECT * FROM posts WHERE channel_id=$1 ORDER BY created_at DESC LIMIT 50`,
|
|
[req.params.id]
|
|
);
|
|
res.json(rows);
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// ── Publish slots ─────────────────────────────────────────────────────────────
|
|
|
|
// GET /api/channels/admin/:id/slots
|
|
router.get('/admin/:id/slots', async (req, res) => {
|
|
try {
|
|
const { rows } = await query(
|
|
`SELECT * FROM publish_slots WHERE channel_id=$1 ORDER BY sort_order, slot_hour, slot_minute`,
|
|
[req.params.id]
|
|
);
|
|
res.json(rows);
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// POST /api/channels/admin/:id/slots
|
|
router.post('/admin/:id/slots', async (req, res) => {
|
|
try {
|
|
const { slot_hour, slot_minute, label, enabled = true } = req.body;
|
|
const { rows: existing } = await query(
|
|
`SELECT COUNT(*) as cnt FROM publish_slots WHERE channel_id=$1`, [req.params.id]
|
|
);
|
|
const sort_order = parseInt(existing[0].cnt);
|
|
const { rows } = await query(
|
|
`INSERT INTO publish_slots (channel_id, slot_hour, slot_minute, label, enabled, sort_order)
|
|
VALUES ($1,$2,$3,$4,$5,$6)
|
|
ON CONFLICT (channel_id, slot_hour, slot_minute) DO UPDATE
|
|
SET label=$4, enabled=$5 RETURNING *`,
|
|
[req.params.id, slot_hour, slot_minute, label || null, enabled, sort_order]
|
|
);
|
|
res.json(rows[0]);
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// DELETE /api/channels/admin/:id/slots/:slotId
|
|
router.delete('/admin/:id/slots/:slotId', async (req, res) => {
|
|
try {
|
|
await query(`DELETE FROM publish_slots WHERE id=$1 AND channel_id=$2`,
|
|
[req.params.slotId, req.params.id]);
|
|
res.json({ ok: true });
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// 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, a.category as article_category
|
|
FROM scheduled_posts sp
|
|
LEFT JOIN articles a ON a.id = sp.article_id
|
|
WHERE sp.channel_id=$1
|
|
ORDER BY sp.scheduled_at DESC LIMIT 30`,
|
|
[req.params.id]
|
|
);
|
|
res.json(rows);
|
|
} catch (err) { res.status(500).json({ error: err.message }); }
|
|
});
|
|
|
|
// 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;
|
|
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, 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 billing = require('../services/billing');
|
|
const bal = await billing.getBalance(userId);
|
|
if (bal.channelsMax !== -1) {
|
|
const { rows: [{ cnt }] } = await require('../config/db').query(
|
|
'SELECT count(*)::int as cnt FROM channels WHERE user_id=$1 AND is_active=true', [userId]
|
|
);
|
|
if (cnt >= bal.channelsMax) {
|
|
return res.status(402).json({
|
|
error: `Лимит каналов по тарифу ${bal.planName}: максимум ${bal.channelsMax}. Перейдите на следующий тариф.`,
|
|
code: 'CHANNEL_LIMIT_REACHED',
|
|
current: cnt,
|
|
max: bal.channelsMax,
|
|
plan: bal.plan,
|
|
});
|
|
}
|
|
}
|
|
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;
|