forked from admin/zeropost-engine
c40ef90ad1
8. SMTP: emailService.js (nodemailer), templates (welcome/payment/low_credits) /api/admin/email/test — тест отправки app_settings category=smtp (HOST/PORT/USER/PASS/FROM/ENABLED) 9. Maintenance mode: middleware в index.js, MAINTENANCE_MODE в engine settings При true → 503 для всех запросов кроме /uploads и /api/settings 10. Blog topic bank: DB: blog_topics(category,topic,is_used,source,priority) 40 тем мигрированы из хардкода (source=hardcoded) autogen.js: getNextTopic берёт из DB, fallback на TOPIC_BANK admin API: GET/POST /blog-topics, DELETE /:id, POST /generate (AI +10)
617 lines
27 KiB
JavaScript
617 lines
27 KiB
JavaScript
/**
|
|
* 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: '<p>Если ты видишь это письмо — SMTP настроен правильно!</p>',
|
|
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}`); }
|
|
});
|