feat: admin dashboard API + separate admin routes file

routes/admin.js: GET /dashboard, /users, POST /credit, PATCH /plans/:id, /credit-costs/:op
index.js: app.use('/api/admin', adminRoutes) — чистый монтаж без хаков
dashboard: users (total/7d/30d), channels by platform, posts (total/today/week),
  revenue (YuKassa), AI costs (30d), registrations chart (14d), pending drafts alert
This commit is contained in:
Ник (Claude)
2026-06-13 00:09:53 +03:00
parent ad9f054701
commit f18b83c59b
3 changed files with 168 additions and 1 deletions
+51
View File
@@ -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 }); }
});