Files
postcast-engine/src/routes/admin.js
T
Ник (Claude) c40ef90ad1 feat: SMTP, maintenance mode, blog topic bank UI
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)
2026-06-13 11:45:23 +03:00

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}`); }
});