/** * admin.js — admin-only API routes. * Монтируется на /api/admin */ const express = require('express'); const router = express.Router(); const { query } = require('../config/db'); function uid(req) { return req.headers['x-user-id'] ? parseInt(req.headers['x-user-id']) : null; } async function requireAdmin(req, res) { const adminId = uid(req); if (!adminId) { res.status(401).json({ error: 'x-user-id required' }); return null; } const { rows: [u] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]); if (!u?.is_admin) { res.status(403).json({ error: 'Forbidden' }); return null; } return adminId; } // GET /api/admin/dashboard router.get('/dashboard', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const [users, channels, posts, drafts, revenue, ai, regs] = await Promise.all([ query(`SELECT count(*)::int as total, count(*) FILTER (WHERE created_at > NOW()-INTERVAL '7 days')::int as new_7d, count(*) FILTER (WHERE created_at > NOW()-INTERVAL '30 days')::int as new_30d FROM users`), query(`SELECT platform, count(*)::int as cnt FROM channels WHERE is_active=true GROUP BY platform`), query(`SELECT count(*)::int as total, count(*) FILTER (WHERE published_at > NOW()-INTERVAL '24 hours')::int as today, count(*) FILTER (WHERE published_at > NOW()-INTERVAL '7 days')::int as week FROM scheduled_posts WHERE status='sent'`), query(`SELECT count(*)::int as pending FROM post_drafts WHERE status='pending'`), query(`SELECT coalesce(sum(amount_rub),0)::int as total_rub, count(*) FILTER (WHERE status='succeeded')::int as paid_count, coalesce(sum(amount_rub) FILTER (WHERE created_at > NOW()-INTERVAL '30 days'),0)::int as month_rub FROM payment_orders WHERE status='succeeded'`), query(`SELECT coalesce(sum(cost_rub),0)::numeric(10,2) as month_rub, count(*)::int as calls, count(*) FILTER (WHERE NOT succeeded)::int as errors FROM ai_usage WHERE created_at > NOW()-INTERVAL '30 days'`), query(`SELECT date_trunc('day', created_at)::date as day, count(*)::int as cnt FROM users WHERE created_at > NOW()-INTERVAL '14 days' GROUP BY 1 ORDER BY 1`), ]); res.json({ users: users.rows[0], channels: channels.rows, posts: posts.rows[0], drafts: drafts.rows[0], revenue: revenue.rows[0], ai: { ...ai.rows[0], cost_rub: parseFloat(ai.rows[0].month_rub) }, registrations_14d: regs.rows, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/admin/users — балансы всех пользователей router.get('/users', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const { rows } = await query(` SELECT u.id, u.email, u.name, u.created_at, ub.credits, ub.reset_at, p.name as plan_name, p.code as plan_code, p.price_rub FROM users u LEFT JOIN user_balance ub ON ub.user_id = u.id LEFT JOIN user_subscriptions us ON us.user_id = u.id AND us.status='active' AND (us.expires_at IS NULL OR us.expires_at > NOW()) LEFT JOIN plans p ON p.id = us.plan_id ORDER BY u.created_at DESC `); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/admin/credit — начислить кредиты router.post('/credit', async (req, res) => { if (!await requireAdmin(req, res)) return; const { user_id, amount, description = 'Ручное начисление' } = req.body; if (!user_id || !amount) return res.status(400).json({ error: 'user_id и amount обязательны' }); try { const billing = require('../services/billing'); const result = await billing.credit(user_id, amount, 'bonus', description, { by_admin: uid(req) }); res.json(result); } catch (err) { res.status(500).json({ error: err.message }); } }); // PATCH /api/admin/plans/:id router.patch('/plans/:id', async (req, res) => { if (!await requireAdmin(req, res)) return; const { price_rub, credits_month, channels_max, name } = req.body; try { const sets = []; const vals = []; if (price_rub !== undefined) sets.push(`price_rub=$${vals.push(price_rub)}`); if (credits_month!== undefined) sets.push(`credits_month=$${vals.push(credits_month)}`); if (channels_max !== undefined) sets.push(`channels_max=$${vals.push(channels_max)}`); if (name !== undefined) sets.push(`name=$${vals.push(name)}`); if (!sets.length) return res.status(400).json({ error: 'nothing to update' }); vals.push(req.params.id); const { rows: [plan] } = await query(`UPDATE plans SET ${sets.join(',')} WHERE id=$${vals.length} RETURNING *`, vals); res.json(plan); } catch (err) { res.status(500).json({ error: err.message }); } }); // PATCH /api/admin/credit-costs/:operation router.patch('/credit-costs/:operation', async (req, res) => { if (!await requireAdmin(req, res)) return; const { credits } = req.body; try { await query('UPDATE credit_costs SET credits=$1 WHERE operation=$2', [credits, req.params.operation]); res.json({ ok: true, operation: req.params.operation, credits }); } catch (err) { res.status(500).json({ error: err.message }); } }); module.exports = router; // GET /api/admin/users/:id — детальная информация о пользователе router.get('/users/:id', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const [user, channels, balance, txs, sub] = await Promise.all([ query(`SELECT id, email, name, is_admin, is_blocked, created_at FROM users WHERE id=$1`, [req.params.id]), query(`SELECT id, name, platform, is_active, tg_username, created_at FROM channels WHERE user_id=$1 ORDER BY created_at DESC`, [req.params.id]), query(`SELECT ub.credits, ub.reset_at, p.name as plan_name, p.code as plan_code, p.price_rub FROM user_balance ub LEFT JOIN user_subscriptions us ON us.user_id=ub.user_id AND us.status='active' LEFT JOIN plans p ON p.id=us.plan_id WHERE ub.user_id=$1`, [req.params.id]), query(`SELECT * FROM user_transactions WHERE user_id=$1 ORDER BY created_at DESC LIMIT 20`, [req.params.id]), query(`SELECT us.*, p.name as plan_name, p.code as plan_code FROM user_subscriptions us JOIN plans p ON p.id=us.plan_id WHERE us.user_id=$1 AND us.status='active' LIMIT 1`, [req.params.id]), ]); if (!user.rows.length) return res.status(404).json({ error: 'User not found' }); res.json({ user: user.rows[0], channels: channels.rows, balance: balance.rows[0] || null, transactions: txs.rows, subscription: sub.rows[0] || null, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PATCH /api/admin/users/:id — обновить пользователя (block/unblock, план) router.patch('/users/:id', async (req, res) => { if (!await requireAdmin(req, res)) return; const { is_blocked, plan_code, name } = req.body; try { if (is_blocked !== undefined) { await query('UPDATE users SET is_blocked=$1 WHERE id=$2', [is_blocked, req.params.id]); } if (name !== undefined) { await query('UPDATE users SET name=$1 WHERE id=$2', [name, req.params.id]); } if (plan_code !== undefined) { // Смена плана вручную const { rows: [plan] } = await query('SELECT * FROM plans WHERE code=$1', [plan_code]); if (!plan) return res.status(400).json({ error: `Plan ${plan_code} not found` }); await query("UPDATE user_subscriptions SET status='cancelled' WHERE user_id=$1 AND status='active'", [req.params.id]); const expires = new Date(Date.now() + 32*24*60*60*1000); await query(`INSERT INTO user_subscriptions (user_id, plan_id, status, expires_at) VALUES ($1,$2,'active',$3)`, [req.params.id, plan.id, expires]); // Начисляем кредиты по новому плану if (plan.credits_month > 0) { await query('UPDATE user_balance SET credits=$1, reset_at=$2 WHERE user_id=$3', [plan.credits_month, expires, req.params.id]); await query(`INSERT INTO user_transactions (user_id, type, amount, balance_after, description) VALUES ($1,'plan_credit',$2,$2,$3)`, [req.params.id, plan.credits_month, `Ручная смена плана на ${plan.name}`]); } } res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── PROMO CODES ────────────────────────────────────────────── // GET /api/admin/promos — список промокодов router.get('/promos', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const { rows } = await query(` SELECT p.*, count(pu.id)::int as uses_real FROM promo_codes p LEFT JOIN promo_usages pu ON pu.code_id = p.id GROUP BY p.id ORDER BY p.created_at DESC `); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/admin/promos — создать промокод router.post('/promos', async (req, res) => { if (!await requireAdmin(req, res)) return; const { code, type = 'credits', value, max_uses = 1, expires_at, description } = req.body; if (!code || !value) return res.status(400).json({ error: 'code и value обязательны' }); if (!['credits','discount_pct'].includes(type)) return res.status(400).json({ error: 'type: credits | discount_pct' }); try { const { rows: [promo] } = await query(` INSERT INTO promo_codes (code, type, value, max_uses, expires_at, description) VALUES ($1,$2,$3,$4,$5,$6) RETURNING * `, [code.toUpperCase(), type, value, max_uses, expires_at || null, description || null]); res.json(promo); } catch (err) { if (err.code === '23505') return res.status(409).json({ error: 'Такой промокод уже существует' }); res.status(500).json({ error: err.message }); } }); // PATCH /api/admin/promos/:id — обновить (деактивировать и т.п.) router.patch('/promos/:id', async (req, res) => { if (!await requireAdmin(req, res)) return; const { is_active, description, max_uses, expires_at } = req.body; try { const sets = []; const vals = []; if (is_active !== undefined) sets.push(`is_active=$${vals.push(is_active)}`); if (description !== undefined) sets.push(`description=$${vals.push(description)}`); if (max_uses !== undefined) sets.push(`max_uses=$${vals.push(max_uses)}`); if (expires_at !== undefined) sets.push(`expires_at=$${vals.push(expires_at)}`); if (!sets.length) return res.status(400).json({ error: 'nothing to update' }); vals.push(req.params.id); const { rows: [p] } = await query(`UPDATE promo_codes SET ${sets.join(',')} WHERE id=$${vals.length} RETURNING *`, vals); res.json(p); } catch (err) { res.status(500).json({ error: err.message }); } }); // DELETE /api/admin/promos/:id router.delete('/promos/:id', async (req, res) => { if (!await requireAdmin(req, res)) return; try { await query('DELETE FROM promo_codes WHERE id=$1', [req.params.id]); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── GENERATION QUEUE ───────────────────────────────────────── // GET /api/admin/queue — статус очереди + последние задачи router.get('/queue', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const [stats, recent, stuck] = await Promise.all([ // Статистика по статусам query(` SELECT status, count(*)::int as cnt, round(avg(extract(epoch from (updated_at - created_at)))::numeric,1) as avg_sec FROM generation_jobs GROUP BY status ORDER BY cnt DESC `), // Последние 30 задач query(` SELECT j.id, j.type, j.status, left(j.topic,60) as topic, left(j.error,120) as error, j.tokens_in, j.tokens_out, j.created_at, j.updated_at, u.email as user_email, c.name as channel_name FROM generation_jobs j LEFT JOIN users u ON u.id = j.user_id LEFT JOIN channels c ON c.id = j.channel_id ORDER BY j.created_at DESC LIMIT 30 `), // Застрявшие (processing > 5 мин) query(` SELECT id, type, topic, created_at, updated_at FROM generation_jobs WHERE status = 'processing' AND updated_at < NOW() - INTERVAL '5 minutes' `), ]); res.json({ stats: stats.rows, recent: recent.rows, stuck: stuck.rows, }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/admin/queue/:id/retry — перезапустить задачу router.post('/queue/:id/retry', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const { rows: [job] } = await query( 'SELECT * FROM generation_jobs WHERE id=$1', [req.params.id] ); if (!job) return res.status(404).json({ error: 'Job not found' }); // Сбрасываем в pending await query(` UPDATE generation_jobs SET status='pending', error=NULL, updated_at=NOW() WHERE id=$1 `, [req.params.id]); // Добавляем в очередь const generationQueue = require('../workers/generation'); if (generationQueue?.add) { await generationQueue.add({ jobId: job.id, type: job.type, topic: job.topic, channelId: job.channel_id, rubric: job.rubric, keywords: [], useCritique: true, }); } res.json({ ok: true, message: `Job ${job.id} requeued` }); } catch (err) { res.status(500).json({ error: err.message }); } }); // DELETE /api/admin/queue/stuck — сбросить застрявшие processing → failed router.delete('/queue/stuck', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const { rows } = await query(` UPDATE generation_jobs SET status='failed', error='Сброшено администратором (stuck)', updated_at=NOW() WHERE status='processing' AND updated_at < NOW() - INTERVAL '5 minutes' RETURNING id `); res.json({ ok: true, cleared: rows.length }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── ERROR LOGS ─────────────────────────────────────────────── // GET /api/admin/logs — последние ошибки из всех источников router.get('/logs', async (req, res) => { if (!await requireAdmin(req, res)) return; const limit = Math.min(parseInt(req.query.limit || 50), 200); try { const [genFailed, aiErrors, scheduledFailed] = await Promise.all([ // Ошибки генерации query(` SELECT 'generation' as source, j.id::text as entity_id, j.type as operation, j.error as message, j.topic as context, u.email as user_email, j.created_at FROM generation_jobs j LEFT JOIN users u ON u.id = j.user_id WHERE j.status = 'failed' AND j.error IS NOT NULL ORDER BY j.created_at DESC LIMIT $1 `, [limit]), // Ошибки AI провайдеров query(` SELECT 'ai_provider' as source, id::text as entity_id, (provider || '/' || request_type) as operation, error_message as message, left(model, 60) as context, NULL as user_email, created_at FROM ai_usage WHERE NOT succeeded AND error_message IS NOT NULL ORDER BY created_at DESC LIMIT $1 `, [limit]), // Ошибки публикации постов query(` SELECT 'publish' as source, sp.id::text as entity_id, (c.platform || ' publish') as operation, 'Failed scheduled post' as message, left(sp.custom_text, 60) as context, NULL as user_email, sp.scheduled_at as created_at FROM scheduled_posts sp JOIN channels c ON c.id = sp.channel_id WHERE sp.status = 'failed' ORDER BY sp.scheduled_at DESC LIMIT 20 `), ]); // Объединяем и сортируем const all = [ ...genFailed.rows, ...aiErrors.rows, ...scheduledFailed.rows, ].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) .slice(0, limit); // Группируем по типу ошибки для статистики const errorGroups = {}; for (const e of all) { const key = e.message?.split('\n')[0]?.slice(0, 80) || 'unknown'; errorGroups[key] = (errorGroups[key] || 0) + 1; } const topErrors = Object.entries(errorGroups) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([msg, cnt]) => ({ msg, cnt })); res.json({ errors: all, total: all.length, topErrors }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── AUTOGEN BLOG ───────────────────────────────────────────── // GET /api/admin/autogen — статус автогенерации блога router.get('/autogen', async (req, res) => { if (!await requireAdmin(req, res)) return; try { const { getAutogenStatus, TOPIC_BANK } = require('../services/autogen'); const status = await getAutogenStatus(); // Статистика статей по категориям за последние 7 дней const { rows: recentStats } = await query(` SELECT category, count(*)::int as cnt_7d, max(created_at) as last_article_at FROM articles WHERE status='published' AND created_at > NOW() - INTERVAL '7 days' GROUP BY category `); const byCategory = Object.fromEntries(recentStats.map(r => [r.category, r])); // Очередь тем const { rows: queueItems } = await query( `SELECT * FROM content_queue ORDER BY priority DESC, created_at ASC LIMIT 20` ); const topicBankSizes = Object.fromEntries( Object.entries(TOPIC_BANK).map(([k, v]) => [k, v.length]) ); res.json({ settings: status, byCategory, queue: queueItems, topicBankSizes }); } catch (err) { res.status(500).json({ error: err.message }); } }); // PATCH /api/admin/autogen/:category — обновить настройки категории router.patch('/autogen/:category', async (req, res) => { if (!await requireAdmin(req, res)) return; 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 (!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); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/admin/autogen/:category/run — запустить генерацию вручную router.post('/autogen/:category/run', async (req, res) => { if (!await requireAdmin(req, res)) return; try { res.json({ ok: true, message: `Генерация категории ${req.params.category} запущена` }); const { runAutogenForCategory } = require('../services/autogen'); runAutogenForCategory(req.params.category).catch(e => console.error(`[Autogen manual] ${req.params.category}: ${e.message}`) ); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/admin/autogen/queue — добавить тему в очередь router.post('/autogen/queue', async (req, res) => { if (!await requireAdmin(req, res)) return; const { category, topic, tags = [], keywords = [], priority = 5 } = req.body; if (!category || !topic) return res.status(400).json({ error: 'category и topic обязательны' }); try { const { rows: [item] } = await query( `INSERT INTO content_queue (category, topic, tags, keywords, priority) VALUES ($1,$2,$3,$4,$5) RETURNING *`, [category, topic, JSON.stringify(tags), JSON.stringify(keywords), priority] ); res.json(item); } catch (err) { res.status(500).json({ error: err.message }); } }); // DELETE /api/admin/autogen/queue/:id router.delete('/autogen/queue/:id', async (req, res) => { if (!await requireAdmin(req, res)) return; try { await query('DELETE FROM content_queue WHERE id=$1', [req.params.id]); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── EMAIL ──────────────────────────────────────────────────── // POST /api/admin/email/test — тестовая отправка router.post('/email/test', async (req, res) => { if (!await requireAdmin(req, res)) return; const { to } = req.body; if (!to) return res.status(400).json({ error: 'to обязателен' }); try { const email = require('../services/emailService'); const result = await email.send({ to, subject: '✅ ZeroPost SMTP тест', html: '

Если ты видишь это письмо — SMTP настроен правильно!

', text: 'Если ты видишь это письмо — SMTP настроен правильно!', }); if (result.skipped) return res.json({ ok: false, message: 'SMTP отключён или не настроен' }); if (result.error) return res.status(500).json({ error: result.error }); res.json({ ok: true, messageId: result.messageId }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── BLOG TOPIC BANK ────────────────────────────────────────── // GET /api/admin/blog-topics — список тем по категории router.get('/blog-topics', async (req, res) => { if (!await requireAdmin(req, res)) return; const { category, includeUsed = 'false', limit = 100 } = req.query; try { const where = category ? 'WHERE bt.category=$1' : ''; const args = category ? [category] : []; const { rows } = await query(` SELECT bt.*, EXISTS(SELECT 1 FROM articles a WHERE a.source_topic=bt.topic) as is_published FROM blog_topics bt ${where} ${includeUsed !== 'true' ? (where ? 'AND' : 'WHERE') + ' bt.is_used=false' : ''} ORDER BY bt.priority DESC, bt.created_at ASC LIMIT ${parseInt(limit)} `, args); // Статистика по категориям const { rows: stats } = await query(` SELECT category, count(*)::int as total, count(*) FILTER (WHERE is_used=false)::int as unused FROM blog_topics GROUP BY category `); res.json({ topics: rows, stats }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/admin/blog-topics — добавить тему router.post('/blog-topics', async (req, res) => { if (!await requireAdmin(req, res)) return; const { category, topic, tags = [], priority = 5 } = req.body; if (!category || !topic) return res.status(400).json({ error: 'category и topic обязательны' }); try { const { rows: [row] } = await query( `INSERT INTO blog_topics (category, topic, tags, priority, source) VALUES ($1,$2,$3,$4,'manual') ON CONFLICT DO NOTHING RETURNING *`, [category, topic.trim(), tags, priority] ); res.json(row || { error: 'Такая тема уже есть' }); } catch (err) { res.status(500).json({ error: err.message }); } }); // DELETE /api/admin/blog-topics/:id router.delete('/blog-topics/:id', async (req, res) => { if (!await requireAdmin(req, res)) return; try { await query('DELETE FROM blog_topics WHERE id=$1', [req.params.id]); res.json({ ok: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/admin/blog-topics/generate — AI генерация новых тем для категории router.post('/blog-topics/generate', async (req, res) => { if (!await requireAdmin(req, res)) return; const { category, count = 10 } = req.body; if (!category) return res.status(400).json({ error: 'category обязателен' }); try { res.json({ ok: true, message: `Генерирую ${count} тем для ${category}...` }); // Берём уже существующие темы для дедупликации const { rows: existing } = await query('SELECT topic FROM blog_topics WHERE category=$1', [category]); const existingTopics = existing.map(r => r.topic).join('\n'); const ai = require('../services/ai'); const config = require('../config'); const CATEGORY_NAMES = { 'ai-tools': 'AI инструменты для работы и бизнеса', 'ai-dev': 'AI разработка и программирование', 'automation': 'Автоматизация процессов', 'cybersec': 'Кибербезопасность', }; const system = `Ты редактор tech-блога. Генерируй темы для статей категории "${CATEGORY_NAMES[category] || category}". Темы должны быть: конкретными, практическими, интересными читателям. Формат: точные заголовки статей, не категории. Ответь ТОЛЬКО JSON-массивом строк без markdown.`; const userMsg = `Придумай ${count} уникальных тем.${existingTopics ? `\n\nИзбегай повторений:\n${existingTopics.slice(0,800)}` : ''}`; const result = await ai.chat( config.ai.models.topics || 'claude-haiku-4-5-20251001', system, userMsg, 0.9, 600 ); const topics = JSON.parse(result.replace(/```json|```/g, '').trim()); let added = 0; for (const topic of topics.slice(0, count)) { if (!topic?.trim()) continue; const { rows: [row] } = await query( `INSERT INTO blog_topics (category, topic, source) VALUES ($1,$2,'ai') ON CONFLICT DO NOTHING RETURNING id`, [category, topic.trim()] ); if (row) added++; } console.log(`[BlogTopics] AI generated ${added} topics for ${category}`); } catch (err) { console.error(`[BlogTopics] generate error: ${err.message}`); } });