diff --git a/index.js b/index.js index 715aab0..67cd842 100644 --- a/index.js +++ b/index.js @@ -105,7 +105,7 @@ app.use('/api/calendar', calendarRoutes); app.use('/api/metrics', metricsRoutes); app.use('/api/usage', usageRoutes); app.use('/api/billing', require('./src/routes/billing')); -app.use('/api/admin', require('./src/routes/billing')); // /admin/plans, /admin/credit-costs +app.use('/api/admin', require('./src/routes/admin')); app.use('/api/channels', require('./src/routes/polls')); app.use('/api', inboxRoutes); app.use('/api', require('./src/routes/drafts')); diff --git a/src/routes/admin.js b/src/routes/admin.js new file mode 100644 index 0000000..2d5d4f0 --- /dev/null +++ b/src/routes/admin.js @@ -0,0 +1,116 @@ +/** + * 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; diff --git a/src/routes/billing.js b/src/routes/billing.js index 389a404..a418afe 100644 --- a/src/routes/billing.js +++ b/src/routes/billing.js @@ -153,3 +153,54 @@ router.patch('/admin/credit-costs/:operation', async (req, res) => { res.json({ ok: true, operation: req.params.operation, credits }); } catch (err) { res.status(500).json({ error: err.message }); } }); + +// GET /api/admin/dashboard — сводная статистика для admin +router.get('/admin/dashboard', async (req, res) => { + const adminId = uid(req); + if (!adminId) return res.status(401).json({ error: 'x-user-id required' }); + const { rows: [admin] } = await query('SELECT is_admin FROM users WHERE id=$1', [adminId]); + if (!admin?.is_admin) return res.status(403).json({ error: 'Forbidden' }); + + 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'`), + // Выручка ЮKassa + 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'`), + // Расходы AI за 30 дней + 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'`), + // Регистрации по дням (последние 14 дней) + 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 }); } +});